mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 10:20:45 +08:00
feat(scheduler): add scheduled jobs with channel-aware delivery
Introduce persisted cron/interval jobs, gateway scheduler loop, worker turns, and admin CRUD/edit UI. Route proactive reminders via the originating chat channel (WhatsApp vs WeChat), harden Weixin outbound with durable queue and PG-compatible polling, and fix chat UI to show each scheduled reminder separately. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5336f8835f
commit
47813f6a94
45 changed files with 4956 additions and 43 deletions
|
|
@ -161,8 +161,18 @@ def run_attempt(*, store: Any, data: AttemptRunnerInput) -> AttemptRunnerOutput:
|
|||
on_tool_ui=data.on_tool_ui,
|
||||
should_stop=data.should_stop,
|
||||
workspace_owner_session_id=_workspace_owner_session_id_from_msg(data.msg),
|
||||
path_policy_tenant_id=str(data.msg.metadata.get("tenant_id") or "") if isinstance(data.msg.metadata, dict) else None,
|
||||
path_policy_user_id=str(data.msg.metadata.get("user_id") or "") if isinstance(data.msg.metadata, dict) else None,
|
||||
path_policy_tenant_id=str(data.msg.tenant_id or "")
|
||||
or (
|
||||
str(data.msg.metadata.get("tenant_id") or "")
|
||||
if isinstance(data.msg.metadata, dict)
|
||||
else ""
|
||||
),
|
||||
path_policy_user_id=str(data.msg.user_id or "")
|
||||
or (
|
||||
str(data.msg.metadata.get("user_id") or "")
|
||||
if isinstance(data.msg.metadata, dict)
|
||||
else ""
|
||||
),
|
||||
workspace_dir=data.workspace_dir,
|
||||
memory_context=data.memory_context,
|
||||
persist_user_message=bool(data.persist_user_message),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class AgentCoreRunInput:
|
|||
skill_binding_role: str | None = None
|
||||
wire_policy_role: str | None = None
|
||||
persisted_user_text: str | None = None
|
||||
persist_user_message: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -204,7 +205,7 @@ def run_agent_core(*, store: Any, data: AgentCoreRunInput) -> AgentCoreRunOutput
|
|||
max_tool_rounds=data.max_tool_rounds,
|
||||
max_tool_workers=data.max_tool_workers,
|
||||
memory_context=mem_ctx,
|
||||
persist_user_message=(idx == 1),
|
||||
persist_user_message=(idx == 1 and bool(data.persist_user_message)),
|
||||
on_token=data.on_token,
|
||||
on_progress=data.on_progress,
|
||||
on_tool_ui=data.on_tool_ui,
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ def _menu_text() -> str:
|
|||
)
|
||||
|
||||
|
||||
def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str) -> str | None:
|
||||
def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str, session_id: str = "") -> str | None:
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return None
|
||||
|
|
@ -175,9 +175,88 @@ def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str) ->
|
|||
lines = [f"- {str(h.get('source') or '')}: {str(h.get('snippet') or '')}" for h in hits[:5] if isinstance(h, dict)]
|
||||
return "知识检索结果:\n" + "\n".join(lines)
|
||||
|
||||
if t in ("查定时任务", "定时任务", "schedules", "/schedule list"):
|
||||
rows = store.scheduled_job_list(tenant_id=tenant_id, status=None, limit=10)
|
||||
if not rows:
|
||||
return "当前没有定时任务。"
|
||||
lines = [
|
||||
f"- {r.name} | {r.schedule_kind}:{r.schedule_expr} | {r.status} | id={r.id[:8]}"
|
||||
for r in rows
|
||||
]
|
||||
return "定时任务:\n" + "\n".join(lines)
|
||||
|
||||
if t.startswith("暂停定时任务 ") or t.startswith("暂停定时 "):
|
||||
prefix = "暂停定时任务 " if t.startswith("暂停定时任务 ") else "暂停定时 "
|
||||
jid = t[len(prefix) :].strip()
|
||||
if not jid:
|
||||
return "请提供任务 id 前缀。示例:暂停定时任务 1234abcd"
|
||||
rows = store.scheduled_job_list(tenant_id=tenant_id, status=None, limit=200)
|
||||
full = next((r.id for r in rows if str(r.id).startswith(jid)), jid)
|
||||
ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=full, status="paused")
|
||||
return "已暂停。" if ok else "未找到该定时任务。"
|
||||
|
||||
if t.startswith("删除定时任务 ") or t.startswith("删除定时 "):
|
||||
prefix = "删除定时任务 " if t.startswith("删除定时任务 ") else "删除定时 "
|
||||
jid = t[len(prefix) :].strip()
|
||||
if not jid:
|
||||
return "请提供任务 id 前缀。示例:删除定时任务 1234abcd"
|
||||
rows = store.scheduled_job_list(tenant_id=tenant_id, status=None, limit=200)
|
||||
full = next((r.id for r in rows if str(r.id).startswith(jid)), jid)
|
||||
ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=full)
|
||||
return "已删除。" if ok else "未找到该定时任务。"
|
||||
|
||||
if t.startswith("记定时 ") or t.startswith("创建定时 "):
|
||||
body = t.split(" ", 1)[1].strip() if " " in t else ""
|
||||
parts = body.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
return "格式:记定时 <时间> <提醒内容>。示例:记定时 5分钟 提醒我休息"
|
||||
when_raw, prompt_text = parts[0].strip(), parts[1].strip()
|
||||
seconds = _parse_schedule_duration_seconds(when_raw)
|
||||
if seconds <= 0:
|
||||
return "无法识别时间。示例:5分钟、1小时、300秒"
|
||||
from runtime.scheduler.cron_service import build_delivery_for_session
|
||||
|
||||
delivery = build_delivery_for_session(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
session_id=str(session_id or "").strip(),
|
||||
)
|
||||
row = store.scheduled_job_create(
|
||||
tenant_id=tenant_id,
|
||||
name=prompt_text[:40] or "定时提醒",
|
||||
prompt_text=prompt_text,
|
||||
schedule_kind="interval",
|
||||
schedule_expr=str(seconds),
|
||||
delivery=delivery,
|
||||
source_session_id=str(session_id or "").strip() or None,
|
||||
created_by_user_id=user_id,
|
||||
source="chat",
|
||||
)
|
||||
return f"已创建定时任务:{row.id[:8]} | {prompt_text} | 每 {seconds} 秒"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_schedule_duration_seconds(raw: str) -> int:
|
||||
import re
|
||||
|
||||
text = str(raw or "").strip().lower()
|
||||
if not text:
|
||||
return 0
|
||||
if text.isdigit():
|
||||
return max(1, int(text))
|
||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*(秒|s|sec|secs|second|seconds|分钟|分|min|mins|小时|时|h|hr|hrs|hour|hours)$", text)
|
||||
if not m:
|
||||
return 0
|
||||
val = float(m.group(1))
|
||||
unit = m.group(2)
|
||||
if unit in {"秒", "s", "sec", "secs", "second", "seconds"}:
|
||||
return max(1, int(val))
|
||||
if unit in {"分钟", "分", "min", "mins"}:
|
||||
return max(1, int(val * 60))
|
||||
return max(1, int(val * 3600))
|
||||
|
||||
|
||||
def _role_can_write(role: str, text: str) -> bool:
|
||||
low = (text or "").strip().lower()
|
||||
if not low:
|
||||
|
|
@ -810,6 +889,24 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
channel_session_id = str(session_id)
|
||||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||
if str(inbound.channel or "").strip().lower() in {"wechat", "weixin"}:
|
||||
from runtime.scheduler.channel_delivery import (
|
||||
extract_context_token_from_inbound_metadata,
|
||||
persist_channel_context_token,
|
||||
)
|
||||
|
||||
ctx_tok = extract_context_token_from_inbound_metadata(
|
||||
inbound.metadata if isinstance(inbound.metadata, dict) else None
|
||||
)
|
||||
if ctx_tok:
|
||||
persist_channel_context_token(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
channel=str(inbound.channel or "weixin"),
|
||||
account_id=account_id,
|
||||
external_chat_id=str(inbound.external_chat_id or inbound.external_user_id or ""),
|
||||
context_token=ctx_tok,
|
||||
)
|
||||
scope = "group" if inbound.is_group else "direct"
|
||||
pe = PolicyEngine()
|
||||
blob = (inbound.text or "").lower()
|
||||
|
|
@ -842,6 +939,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
text=inbound.text,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(session_id),
|
||||
)
|
||||
if cmd_reply is not None:
|
||||
reply = cmd_reply
|
||||
|
|
|
|||
|
|
@ -493,7 +493,21 @@ class ToolExecutor:
|
|||
msg = f"Unregistered tool: {tc.name}" if ctx.lang.startswith("en") else f"未注册的工具: {tc.name}"
|
||||
return {"ok": False, "error_code": "tool_not_registered", "error": msg}, int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
ok, v_err = validate_tool_arguments(tool.parameters, tc.arguments)
|
||||
from runtime.tools.context_inject import enrich_tool_arguments
|
||||
from runtime.tools.tool_validation import filter_arguments_to_schema
|
||||
|
||||
raw_args = tc.arguments if isinstance(tc.arguments, dict) else {}
|
||||
tool_args = enrich_tool_arguments(
|
||||
store=ctx.store,
|
||||
session_id=ctx.session_id,
|
||||
tool_name=str(tc.name or ""),
|
||||
arguments=raw_args,
|
||||
path_policy_tenant_id=ctx.path_policy_tenant_id,
|
||||
path_policy_user_id=ctx.path_policy_user_id,
|
||||
)
|
||||
tool_args = filter_arguments_to_schema(tool.parameters, tool_args)
|
||||
|
||||
ok, v_err = validate_tool_arguments(tool.parameters, tool_args)
|
||||
if not ok:
|
||||
msg = f"Invalid arguments: {v_err}" if ctx.lang.startswith("en") else f"参数不合法: {v_err}"
|
||||
return {"ok": False, "error_code": "tool_invalid_arguments", "error": msg}, int((time.perf_counter() - t0) * 1000)
|
||||
|
|
@ -530,7 +544,7 @@ class ToolExecutor:
|
|||
|
||||
netx_lang_token = NETX_TOOL_LANG.set(str(ctx.lang or "zh"))
|
||||
try:
|
||||
return tool.handler(tc.arguments)
|
||||
return tool.handler(tool_args)
|
||||
finally:
|
||||
if netx_lang_token is not None:
|
||||
from runtime.tools.experts.network_ops.netx_tools import NETX_TOOL_LANG
|
||||
|
|
|
|||
|
|
@ -690,6 +690,14 @@ def _build_model_context(
|
|||
)
|
||||
except Exception:
|
||||
pass
|
||||
if str(user_text or "").strip() and str(active_turn_uuid or "").strip():
|
||||
has_turn_user = any(
|
||||
str(getattr(m, "role", "") or "") == "user"
|
||||
and str(getattr(m, "turn_uuid", "") or "") == str(active_turn_uuid)
|
||||
for m in (rows or [])
|
||||
)
|
||||
if not has_turn_user:
|
||||
llm_messages.append({"role": "user", "content": str(user_text).strip()})
|
||||
return llm_messages
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -192,6 +192,154 @@ function resolveHeaders(token: string): Record<string, string> {
|
|||
};
|
||||
}
|
||||
|
||||
async function postLocalJson(token: string, route: string, body: Json, timeoutMs = 8000): Promise<Json> {
|
||||
const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/${route.replace(/^\/+/, "")}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: resolveHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new HttpStatusError(res.status, `${route} ${res.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
return text ? (JSON.parse(text) as Json) : {};
|
||||
}
|
||||
|
||||
async function getLocalJson(token: string, route: string, timeoutMs = 8000): Promise<Json> {
|
||||
const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/${route.replace(/^\/+/, "")}`;
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: resolveHeaders(token),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new HttpStatusError(res.status, `${route} ${res.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
return text ? (JSON.parse(text) as Json) : {};
|
||||
}
|
||||
|
||||
async function flushWeixinDbOutbound(args: {
|
||||
modules: OfficialModules;
|
||||
token: string;
|
||||
accountId: string;
|
||||
cloudBaseUrl: string;
|
||||
userContextTokens: TokenMap;
|
||||
}): Promise<void> {
|
||||
const q = encodeURIComponent(args.accountId);
|
||||
const out = await getLocalJson(args.token, `weixin/outbound/pending?account_id=${q}&limit=20`, 8000);
|
||||
const items = Array.isArray(out.items) ? (out.items as Json[]) : [];
|
||||
for (const item of items) {
|
||||
const toUser = String(item.chat_id || "").trim();
|
||||
const text = String(item.text || "").trim();
|
||||
const msgId = String(item.id || "").trim();
|
||||
if (!toUser || !text || !msgId) continue;
|
||||
const contextToken = String(
|
||||
(item.context_token as string) ||
|
||||
args.modules.getContextToken(args.accountId, toUser) ||
|
||||
args.userContextTokens[toUser] ||
|
||||
"",
|
||||
).trim();
|
||||
if (!contextToken) {
|
||||
log(`db proactive reply missing context_token; keep pending. id=${msgId} to=${toUser}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await args.modules.sendMessageWeixin({
|
||||
to: toUser,
|
||||
text,
|
||||
opts: {
|
||||
baseUrl: args.cloudBaseUrl,
|
||||
token: args.token,
|
||||
contextToken,
|
||||
},
|
||||
});
|
||||
await postLocalJson(args.token, "weixin/outbound/ack", { id: msgId, ok: true }, 8000);
|
||||
log(`db proactive reply sent: id=${msgId} to=${toUser} textLen=${text.length}`);
|
||||
} catch (err) {
|
||||
try {
|
||||
await postLocalJson(
|
||||
args.token,
|
||||
"weixin/outbound/ack",
|
||||
{ id: msgId, ok: false, error: String(err) },
|
||||
8000,
|
||||
);
|
||||
} catch (_) {
|
||||
// ignore ack failure
|
||||
}
|
||||
log(`db proactive reply failed: id=${msgId} to=${toUser} err=${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function flushLocalProactiveReplies(args: {
|
||||
modules: OfficialModules;
|
||||
token: string;
|
||||
accountId: string;
|
||||
cloudBaseUrl: string;
|
||||
localCursor: string;
|
||||
userContextTokens: TokenMap;
|
||||
}): Promise<string> {
|
||||
let cursor = args.localCursor;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const out = await postLocalJson(
|
||||
args.token,
|
||||
"ilink/bot/getupdates",
|
||||
{
|
||||
channel: "wechat",
|
||||
account_id: args.accountId,
|
||||
get_updates_buf: cursor,
|
||||
longpolling_timeout_ms: 1000,
|
||||
limit: 20,
|
||||
},
|
||||
8000,
|
||||
);
|
||||
const msgs = Array.isArray(out.msgs) ? (out.msgs as Json[]) : [];
|
||||
const next = String(out.get_updates_buf || cursor || "").trim();
|
||||
const batchCursor = cursor;
|
||||
const nextCursor = next || cursor;
|
||||
if (!msgs.length) {
|
||||
break;
|
||||
}
|
||||
let allSucceeded = true;
|
||||
for (const r of msgs) {
|
||||
const toUser = String(r.chat_id || "").trim();
|
||||
const text = String(r.text || "").trim();
|
||||
if (!toUser || !text) continue;
|
||||
const contextToken = String(
|
||||
(r.context_token as string) ||
|
||||
args.modules.getContextToken(args.accountId, toUser) ||
|
||||
args.userContextTokens[toUser] ||
|
||||
"",
|
||||
).trim();
|
||||
if (!contextToken) {
|
||||
allSucceeded = false;
|
||||
log(`proactive reply missing context_token; keep cursor. to=${toUser} textLen=${text.length}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await args.modules.sendMessageWeixin({
|
||||
to: toUser,
|
||||
text,
|
||||
opts: {
|
||||
baseUrl: args.cloudBaseUrl,
|
||||
token: args.token,
|
||||
contextToken,
|
||||
},
|
||||
});
|
||||
log(`proactive reply sent: to=${toUser} textLen=${text.length}`);
|
||||
} catch (err) {
|
||||
log(`proactive reply failed: to=${toUser} err=${String(err)}`);
|
||||
allSucceeded = false;
|
||||
}
|
||||
}
|
||||
cursor = allSucceeded ? nextCursor : batchCursor;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
async function postNativeReply(token: string, body: Json): Promise<Json> {
|
||||
const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/weixin/native/reply`;
|
||||
const timeoutMs = Number(process.env.OCLAW_WEIXIN_NATIVE_REPLY_TIMEOUT_MS || "100000") || 100000;
|
||||
|
|
@ -490,6 +638,7 @@ async function main(): Promise<void> {
|
|||
ensureDir(STATE_DIR);
|
||||
const state = (readJsonFile<Json>(STATE_FILE) || {}) as Json;
|
||||
let cloudCursor = String(state.cloud_cursor || "").trim();
|
||||
let localCursor = String(state.local_cursor || "").trim();
|
||||
const userContextTokens: TokenMap =
|
||||
state.user_context_tokens && typeof state.user_context_tokens === "object"
|
||||
? (state.user_context_tokens as TokenMap)
|
||||
|
|
@ -500,6 +649,25 @@ async function main(): Promise<void> {
|
|||
log(`official runner started account=${accountId} cloud=${cloudBaseUrl} local=${LOCAL_BASE_URL}`);
|
||||
while (true) {
|
||||
try {
|
||||
try {
|
||||
await flushWeixinDbOutbound({
|
||||
modules,
|
||||
token,
|
||||
accountId,
|
||||
cloudBaseUrl,
|
||||
userContextTokens,
|
||||
});
|
||||
} catch (err) {
|
||||
log(`db proactive flush error: ${String(err)}`);
|
||||
}
|
||||
localCursor = await flushLocalProactiveReplies({
|
||||
modules,
|
||||
token,
|
||||
accountId,
|
||||
cloudBaseUrl,
|
||||
localCursor,
|
||||
userContextTokens,
|
||||
});
|
||||
const out = await modules.getUpdates({
|
||||
baseUrl: cloudBaseUrl,
|
||||
token,
|
||||
|
|
@ -530,6 +698,7 @@ async function main(): Promise<void> {
|
|||
}
|
||||
writeJsonFile(STATE_FILE, {
|
||||
cloud_cursor: cloudCursor,
|
||||
local_cursor: localCursor,
|
||||
user_context_tokens: userContextTokens,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
|
|
|||
3
runtime/scheduler/__init__.py
Normal file
3
runtime/scheduler/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
252
runtime/scheduler/channel_delivery.py
Normal file
252
runtime/scheduler/channel_delivery.py
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from runtime.orchestration.group_ingest import is_nonsend_channel_reply_text, should_send_channel_reply_text
|
||||
from runtime.scheduler.session_resolver import parse_delivery_json
|
||||
|
||||
|
||||
def _encode_weixin_outbound_source(*, context_token: str) -> str:
|
||||
return json.dumps(
|
||||
{"kind": "scheduled_job", "context_token": str(context_token or "").strip()},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _decode_weixin_outbound_source(raw: str) -> dict[str, Any]:
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(text)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def enqueue_weixin_reply(
|
||||
*,
|
||||
channel: str,
|
||||
account_id: str,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
context_token: str = "",
|
||||
store: Any = None,
|
||||
tenant_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
from runtime.scheduler.weixin_delivery import normalize_weixin_channel
|
||||
|
||||
ctx_tok = str(context_token or "").strip()
|
||||
if not ctx_tok:
|
||||
return {
|
||||
"ok": False,
|
||||
"channel": normalize_weixin_channel(channel),
|
||||
"error": "context_token_missing",
|
||||
"hint": "Send any message to the bot on WeChat first, then retry the scheduled job.",
|
||||
}
|
||||
durable_id = ""
|
||||
enqueuer = getattr(store, "enqueue_channel_outbound_message", None)
|
||||
if store is not None and callable(enqueuer):
|
||||
try:
|
||||
durable_id = str(
|
||||
enqueuer(
|
||||
channel=normalize_weixin_channel(channel),
|
||||
chat_id=str(chat_id or "").strip(),
|
||||
text=str(text or ""),
|
||||
tenant_id=str(tenant_id or ""),
|
||||
account_id=str(account_id or "").strip(),
|
||||
source=_encode_weixin_outbound_source(context_token=ctx_tok),
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
except Exception:
|
||||
durable_id = ""
|
||||
try:
|
||||
from interfaces.http.weixin_ilink_api import enqueue_weixin_outbound_reply
|
||||
except Exception as exc:
|
||||
if durable_id:
|
||||
return {
|
||||
"ok": True,
|
||||
"channel": normalize_weixin_channel(channel),
|
||||
"message_id": durable_id,
|
||||
"queued": True,
|
||||
"durable": True,
|
||||
"context_token_present": True,
|
||||
"bridge_error": f"{type(exc).__name__}: {exc}",
|
||||
}
|
||||
return {"ok": False, "channel": channel, "error": f"{type(exc).__name__}: {exc}"}
|
||||
try:
|
||||
bridge_seq = enqueue_weixin_outbound_reply(
|
||||
channel=normalize_weixin_channel(channel),
|
||||
account_id=str(account_id or "").strip(),
|
||||
chat_id=str(chat_id or "").strip(),
|
||||
text=text,
|
||||
context_token=ctx_tok,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"channel": normalize_weixin_channel(channel),
|
||||
"message_id": durable_id or bridge_seq,
|
||||
"bridge_seq": bridge_seq,
|
||||
"queued": True,
|
||||
"durable": bool(durable_id),
|
||||
"context_token_present": True,
|
||||
"account_id": str(account_id or "").strip(),
|
||||
"chat_id": str(chat_id or "").strip(),
|
||||
}
|
||||
except Exception as exc:
|
||||
if durable_id:
|
||||
return {
|
||||
"ok": True,
|
||||
"channel": normalize_weixin_channel(channel),
|
||||
"message_id": durable_id,
|
||||
"queued": True,
|
||||
"durable": True,
|
||||
"context_token_present": True,
|
||||
"bridge_error": f"{type(exc).__name__}: {exc}",
|
||||
}
|
||||
return {"ok": False, "channel": channel, "error": f"{type(exc).__name__}: {exc}"}
|
||||
|
||||
|
||||
def persist_channel_context_token(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
channel: str,
|
||||
account_id: str,
|
||||
external_chat_id: str,
|
||||
context_token: str,
|
||||
) -> None:
|
||||
from runtime.scheduler.weixin_delivery import normalize_weixin_channel
|
||||
|
||||
setter = getattr(store, "set_channel_context_token", None)
|
||||
if not callable(setter):
|
||||
return
|
||||
tok = str(context_token or "").strip()
|
||||
chat_id = str(external_chat_id or "").strip()
|
||||
if not tok or not chat_id:
|
||||
return
|
||||
tid = str(tenant_id or "")
|
||||
acct = str(account_id or "").strip()
|
||||
channels = []
|
||||
for ch in (channel, "wechat", "weixin"):
|
||||
c = normalize_weixin_channel(str(ch or "")) if str(ch or "").lower() in {"wechat", "weixin"} else str(ch or "").strip().lower()
|
||||
if c and c not in channels:
|
||||
channels.append(c)
|
||||
account_ids = [acct] if acct else [""]
|
||||
if acct:
|
||||
account_ids.append("")
|
||||
for ch in channels:
|
||||
for aid in account_ids:
|
||||
setter(
|
||||
tenant_id=tid,
|
||||
channel=ch,
|
||||
account_id=aid,
|
||||
external_chat_id=chat_id,
|
||||
context_token=tok,
|
||||
)
|
||||
|
||||
|
||||
def extract_context_token_from_inbound_metadata(metadata: dict[str, Any] | None) -> str:
|
||||
meta = metadata if isinstance(metadata, dict) else {}
|
||||
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
|
||||
msg = raw.get("msg") if isinstance(raw.get("msg"), dict) else {}
|
||||
for candidate in (
|
||||
msg.get("context_token"),
|
||||
(raw.get("metadata") or {}).get("context_token") if isinstance(raw.get("metadata"), dict) else None,
|
||||
raw.get("context_token"),
|
||||
meta.get("context_token"),
|
||||
):
|
||||
tok = str(candidate or "").strip()
|
||||
if tok:
|
||||
return tok
|
||||
return ""
|
||||
|
||||
|
||||
def deliver_scheduled_reply(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
reply_text: str,
|
||||
delivery_json: str,
|
||||
resolved_channel: str = "",
|
||||
resolved_chat_id: str = "",
|
||||
resolved_account_id: str = "",
|
||||
session_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
text = str(reply_text or "").strip()
|
||||
if not text or is_nonsend_channel_reply_text(text):
|
||||
return {"ok": False, "skipped": True, "reason": "empty_or_silent_reply"}
|
||||
|
||||
ch_lower = str(resolved_channel or "").strip().lower()
|
||||
if ch_lower in {"wechat", "weixin"}:
|
||||
from runtime.application.gateway.inbound_service import _user_facing_wechat_reply
|
||||
|
||||
text = _user_facing_wechat_reply(reply=text)
|
||||
|
||||
delivery = parse_delivery_json(delivery_json)
|
||||
results: dict[str, Any] = {}
|
||||
|
||||
wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {}
|
||||
wa_enabled = bool(wa.get("enabled")) and str(wa.get("target_type") or "none") != "none"
|
||||
chat_id = str(wa.get("chat_id") or resolved_chat_id or "").strip()
|
||||
account_id = str(
|
||||
wa.get("account_id") or resolved_account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"
|
||||
).strip()
|
||||
if wa_enabled and chat_id and should_send_channel_reply_text(text):
|
||||
msg_id = store.enqueue_channel_outbound_message(
|
||||
channel="whatsapp",
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
source="scheduled_job",
|
||||
)
|
||||
results["whatsapp"] = {"ok": True, "message_id": msg_id, "chat_id": chat_id}
|
||||
|
||||
wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
|
||||
wx_enabled = bool(wx.get("enabled", True))
|
||||
if wx_enabled and should_send_channel_reply_text(text):
|
||||
from runtime.scheduler.weixin_delivery import resolve_weixin_delivery_target
|
||||
|
||||
target = resolve_weixin_delivery_target(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
session_id=session_id,
|
||||
delivery=delivery,
|
||||
resolved_channel=resolved_channel,
|
||||
resolved_chat_id=resolved_chat_id,
|
||||
resolved_account_id=resolved_account_id,
|
||||
)
|
||||
wx_chat = str(target.get("chat_id") or "").strip()
|
||||
wx_account = str(target.get("account_id") or "").strip()
|
||||
wx_channel = str(target.get("channel") or "wechat")
|
||||
context_token = str(target.get("context_token") or "").strip()
|
||||
if wx_chat:
|
||||
results["weixin"] = enqueue_weixin_reply(
|
||||
channel=wx_channel,
|
||||
account_id=wx_account,
|
||||
chat_id=wx_chat,
|
||||
text=text,
|
||||
context_token=context_token,
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
else:
|
||||
results["weixin"] = {"ok": False, "error": "weixin_chat_missing"}
|
||||
|
||||
if not results:
|
||||
return {"ok": True, "skipped": True, "reason": "no_delivery_targets"}
|
||||
|
||||
ok = all(bool((v or {}).get("ok")) for v in results.values() if isinstance(v, dict))
|
||||
return {"ok": ok, "channels": results}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"deliver_scheduled_reply",
|
||||
"enqueue_weixin_reply",
|
||||
"extract_context_token_from_inbound_metadata",
|
||||
"persist_channel_context_token",
|
||||
]
|
||||
201
runtime/scheduler/cron_service.py
Normal file
201
runtime/scheduler/cron_service.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from runtime.scheduler.expressions import compute_next_run_at, normalize_schedule_kind
|
||||
from runtime.scheduler.service import run_scheduled_job_now
|
||||
from runtime.scheduler.session_resolver import parse_delivery_json, resolve_weixin_binding
|
||||
|
||||
|
||||
class CronService:
|
||||
def __init__(self, *, store: Any) -> None:
|
||||
self.store = store
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {"running": True}
|
||||
|
||||
def wake(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"ok": True, **dict(params or {})}
|
||||
|
||||
def listPage(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
tenant_id = str((params or {}).get("tenantId") or (params or {}).get("tenant_id") or "default").strip()
|
||||
status = str((params or {}).get("enabled") or (params or {}).get("status") or "").strip() or None
|
||||
if status in {"true", "1"}:
|
||||
status = "active"
|
||||
elif status in {"false", "0"}:
|
||||
status = "paused"
|
||||
limit = int((params or {}).get("limit") or 50)
|
||||
offset = int((params or {}).get("offset") or 0)
|
||||
rows = self.store.scheduled_job_list(
|
||||
tenant_id=tenant_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
items = [self._job_to_gateway_item(self.store.scheduled_job_to_dict(r)) for r in rows]
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
def add(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
p = dict(params or {})
|
||||
tenant_id = str(p.get("tenantId") or p.get("tenant_id") or "default").strip()
|
||||
name = str(p.get("name") or p.get("schedule") or "cron job").strip()
|
||||
schedule = str(p.get("schedule") or p.get("schedule_expr") or "").strip()
|
||||
schedule_kind = normalize_schedule_kind(p.get("schedule_kind") or p.get("scheduleKind") or "cron")
|
||||
prompt = str(p.get("prompt") or p.get("prompt_text") or p.get("text") or name).strip()
|
||||
job = self.store.scheduled_job_create(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
prompt_text=prompt,
|
||||
schedule_kind=schedule_kind,
|
||||
schedule_expr=schedule,
|
||||
timezone_name=str(p.get("timezone") or "Asia/Shanghai"),
|
||||
description=str(p.get("description") or ""),
|
||||
interaction_mode=str(p.get("interaction_mode") or "expert"),
|
||||
specialist=str(p.get("specialist") or "generalist"),
|
||||
lang=str(p.get("lang") or "zh"),
|
||||
delivery=p.get("delivery") if isinstance(p.get("delivery"), dict) else {},
|
||||
source="gateway",
|
||||
)
|
||||
return self._job_to_gateway_item(self.store.scheduled_job_to_dict(job))
|
||||
|
||||
def update(self, job_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
p = dict(patch or {})
|
||||
tenant_id = str(p.pop("tenantId", None) or p.pop("tenant_id", None) or "default").strip()
|
||||
mapped: dict[str, Any] = {}
|
||||
for src, dst in (
|
||||
("name", "name"),
|
||||
("schedule", "schedule_expr"),
|
||||
("schedule_kind", "schedule_kind"),
|
||||
("prompt", "prompt_text"),
|
||||
("prompt_text", "prompt_text"),
|
||||
("timezone", "timezone"),
|
||||
("interaction_mode", "interaction_mode"),
|
||||
("specialist", "specialist"),
|
||||
("lang", "lang"),
|
||||
("delivery", "delivery"),
|
||||
("enabled", "status"),
|
||||
):
|
||||
if src in p:
|
||||
mapped[dst] = p[src]
|
||||
if "enabled" in mapped:
|
||||
mapped["status"] = "active" if bool(mapped.pop("enabled")) else "paused"
|
||||
job = self.store.scheduled_job_update(tenant_id=tenant_id, job_id=str(job_id), patch=mapped)
|
||||
if not job:
|
||||
return {"id": job_id, "ok": False}
|
||||
return self._job_to_gateway_item(self.store.scheduled_job_to_dict(job))
|
||||
|
||||
def remove(self, job_id: str) -> dict[str, Any]:
|
||||
rows = self.store.scheduled_job_list(tenant_id="default", limit=500)
|
||||
for row in rows:
|
||||
if str(row.id) == str(job_id):
|
||||
self.store.scheduled_job_delete(tenant_id=row.tenant_id, job_id=str(job_id))
|
||||
return {"removed": True, "id": job_id}
|
||||
self.store.scheduled_job_delete(tenant_id="default", job_id=str(job_id))
|
||||
return {"removed": True, "id": job_id}
|
||||
|
||||
def enqueueRun(self, job_id: str, mode: str = "force") -> dict[str, Any]:
|
||||
rows = self.store.scheduled_job_list(tenant_id="default", limit=500)
|
||||
tenant_id = "default"
|
||||
for row in rows:
|
||||
if str(row.id) == str(job_id):
|
||||
tenant_id = str(row.tenant_id)
|
||||
break
|
||||
out = run_scheduled_job_now(self.store, tenant_id=tenant_id, job_id=str(job_id))
|
||||
return {"ok": bool(out.get("ok")), "ran": bool(out.get("ok")), "jobId": job_id, "mode": mode, **out}
|
||||
|
||||
def listRuns(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
p = dict(params or {})
|
||||
job_id = str(p.get("jobId") or p.get("id") or "").strip()
|
||||
tenant_id = str(p.get("tenantId") or p.get("tenant_id") or "default").strip()
|
||||
limit = int(p.get("limit") or 50)
|
||||
if not job_id:
|
||||
return {"items": [], "total": 0}
|
||||
rows = self.store.scheduled_job_run_list(job_id=job_id, tenant_id=tenant_id, limit=limit)
|
||||
items = [self.store.scheduled_job_run_to_dict(r) for r in rows]
|
||||
return {"items": items, "total": len(items), "jobId": job_id}
|
||||
|
||||
def _job_to_gateway_item(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"schedule": row.get("schedule_expr"),
|
||||
"schedule_kind": row.get("schedule_kind"),
|
||||
"enabled": str(row.get("status") or "") == "active",
|
||||
"prompt": row.get("prompt_text"),
|
||||
"timezone": row.get("timezone"),
|
||||
"nextRunAt": row.get("next_run_at"),
|
||||
"lastRunAt": row.get("last_run_at"),
|
||||
"specialist": row.get("specialist"),
|
||||
"interaction_mode": row.get("interaction_mode"),
|
||||
"delivery": row.get("delivery"),
|
||||
}
|
||||
|
||||
|
||||
def build_default_delivery(*, store: Any, tenant_id: str, whatsapp_chat_id: str = "") -> dict[str, Any]:
|
||||
import os
|
||||
|
||||
delivery: dict[str, Any] = {
|
||||
"whatsapp": {
|
||||
"enabled": bool(str(whatsapp_chat_id or "").strip()),
|
||||
"target_type": "group" if str(whatsapp_chat_id or "").endswith("@g.us") else "direct",
|
||||
"chat_id": str(whatsapp_chat_id or ""),
|
||||
"account_id": str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"),
|
||||
},
|
||||
"weixin": {"enabled": True, "fixed": True},
|
||||
}
|
||||
if not delivery["whatsapp"]["enabled"]:
|
||||
delivery["whatsapp"]["target_type"] = "none"
|
||||
binding = resolve_weixin_binding(store, tenant_id=tenant_id)
|
||||
if binding:
|
||||
ext = str(binding.get("external_user_id") or "")
|
||||
delivery["weixin"]["external_user_id"] = ext
|
||||
delivery["weixin"]["external_chat_id"] = str(binding.get("external_chat_id") or ext)
|
||||
delivery["weixin"]["account_id"] = str(binding.get("account_id") or "weixin-default")
|
||||
return delivery
|
||||
|
||||
|
||||
def build_delivery_for_session(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
session_id: str = "",
|
||||
whatsapp_chat_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Pick delivery targets from the chat session that created the job (WhatsApp vs WeChat)."""
|
||||
import os
|
||||
|
||||
tid = str(tenant_id or "").strip()
|
||||
sid = str(session_id or "").strip()
|
||||
explicit_wa = str(whatsapp_chat_id or "").strip()
|
||||
lookup = getattr(store, "lookup_channel_session_by_session_id", None)
|
||||
if sid and callable(lookup):
|
||||
ctx = lookup(tenant_id=tid, session_id=sid)
|
||||
if isinstance(ctx, dict):
|
||||
ch = str(ctx.get("channel") or "").strip().lower()
|
||||
chat_id = str(ctx.get("external_chat_id") or "").strip()
|
||||
acct = str(ctx.get("account_id") or "").strip()
|
||||
if ch == "whatsapp" and chat_id:
|
||||
return {
|
||||
"whatsapp": {
|
||||
"enabled": True,
|
||||
"target_type": "group" if chat_id.endswith("@g.us") else "direct",
|
||||
"chat_id": chat_id,
|
||||
"account_id": acct or str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"),
|
||||
},
|
||||
"weixin": {"enabled": False, "fixed": False},
|
||||
}
|
||||
if ch in {"weixin", "wechat"}:
|
||||
delivery = build_default_delivery(store=store, tenant_id=tid, whatsapp_chat_id="")
|
||||
wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {}
|
||||
delivery["whatsapp"] = {
|
||||
**wa,
|
||||
"enabled": False,
|
||||
"target_type": "none",
|
||||
"chat_id": "",
|
||||
}
|
||||
return delivery
|
||||
return build_default_delivery(store=store, tenant_id=tid, whatsapp_chat_id=explicit_wa)
|
||||
|
||||
|
||||
__all__ = ["CronService", "build_default_delivery", "build_delivery_for_session", "compute_next_run_at"]
|
||||
77
runtime/scheduler/expressions.py
Normal file
77
runtime/scheduler/expressions.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
try:
|
||||
from croniter import croniter
|
||||
except ImportError: # pragma: no cover - guarded in requirements
|
||||
croniter = None # type: ignore[assignment,misc]
|
||||
|
||||
|
||||
def normalize_schedule_kind(raw: Any) -> str:
|
||||
kind = str(raw or "").strip().lower()
|
||||
if kind in {"cron", "once", "interval"}:
|
||||
return kind
|
||||
return "cron"
|
||||
|
||||
|
||||
def _parse_iso_dt(value: str) -> datetime:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ValueError("empty datetime")
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
dt = datetime.fromisoformat(text)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def compute_next_run_at(
|
||||
*,
|
||||
schedule_kind: str,
|
||||
schedule_expr: str,
|
||||
timezone_name: str = "Asia/Shanghai",
|
||||
from_dt: datetime | None = None,
|
||||
) -> str | None:
|
||||
kind = normalize_schedule_kind(schedule_kind)
|
||||
expr = str(schedule_expr or "").strip()
|
||||
if not expr:
|
||||
return None
|
||||
base = from_dt or datetime.now(timezone.utc)
|
||||
if base.tzinfo is None:
|
||||
base = base.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
base = base.astimezone(timezone.utc)
|
||||
|
||||
if kind == "once":
|
||||
target = _parse_iso_dt(expr)
|
||||
if target <= base:
|
||||
return None
|
||||
return target.isoformat()
|
||||
|
||||
if kind == "interval":
|
||||
try:
|
||||
seconds = max(1, int(expr))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"invalid interval seconds: {expr}") from exc
|
||||
nxt = base + timedelta(seconds=seconds)
|
||||
return nxt.isoformat()
|
||||
|
||||
if croniter is None:
|
||||
raise RuntimeError("croniter is required for cron schedules")
|
||||
try:
|
||||
tz = ZoneInfo(str(timezone_name or "Asia/Shanghai"))
|
||||
except Exception:
|
||||
tz = ZoneInfo("Asia/Shanghai")
|
||||
local_base = base.astimezone(tz)
|
||||
itr = croniter(expr, local_base)
|
||||
nxt_local = itr.get_next(datetime)
|
||||
if nxt_local.tzinfo is None:
|
||||
nxt_local = nxt_local.replace(tzinfo=tz)
|
||||
return nxt_local.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
__all__ = ["compute_next_run_at", "normalize_schedule_kind"]
|
||||
200
runtime/scheduler/service.py
Normal file
200
runtime/scheduler/service.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from runtime.scheduler.session_resolver import resolve_scheduled_session, resolve_scheduled_viewer_username
|
||||
from runtime.scheduler.turn_text import build_scheduled_turn_instruction
|
||||
from runtime.worker import ensure_worker_started
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_THREAD: threading.Thread | None = None
|
||||
_RUNNING = False
|
||||
|
||||
|
||||
def _tick_interval_seconds() -> float:
|
||||
import os
|
||||
|
||||
raw = str(os.getenv("AIA_SCHEDULER_TICK_SECONDS") or "30").strip()
|
||||
try:
|
||||
return max(5.0, min(float(raw), 3600.0))
|
||||
except Exception:
|
||||
return 30.0
|
||||
|
||||
|
||||
def enqueue_scheduled_job_run(
|
||||
store: Any,
|
||||
*,
|
||||
job: Any,
|
||||
mode: str = "scheduled",
|
||||
) -> dict[str, Any]:
|
||||
tenant_id = str(getattr(job, "tenant_id", "") or "")
|
||||
job_id = str(getattr(job, "id") or "")
|
||||
run = store.scheduled_job_run_create(
|
||||
job_id=job_id,
|
||||
tenant_id=tenant_id,
|
||||
scheduled_at=str(getattr(job, "next_run_at", "") or datetime.now(timezone.utc).isoformat()),
|
||||
status="queued",
|
||||
)
|
||||
try:
|
||||
resolved = resolve_scheduled_session(
|
||||
store,
|
||||
job=job,
|
||||
created_by_user_id=str(getattr(job, "created_by_user_id", "") or ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
store.scheduled_job_run_update(
|
||||
run_id=run.id,
|
||||
tenant_id=tenant_id,
|
||||
patch={
|
||||
"status": "failed",
|
||||
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
store.scheduled_job_mark_run(
|
||||
job_id=job_id,
|
||||
tenant_id=tenant_id,
|
||||
last_run_status="failed",
|
||||
pause_after=False,
|
||||
)
|
||||
return {"ok": False, "error": str(exc), "run_id": run.id}
|
||||
|
||||
delivery: dict[str, Any] = {}
|
||||
try:
|
||||
raw = json.loads(str(getattr(job, "delivery_json", "") or "{}"))
|
||||
if isinstance(raw, dict):
|
||||
delivery = raw
|
||||
except Exception:
|
||||
delivery = {}
|
||||
|
||||
trace_id = uuid.uuid4().hex
|
||||
agent_run_id = uuid.uuid4().hex
|
||||
prompt_text = str(getattr(job, "prompt_text", "") or "").strip()
|
||||
lang = str(getattr(job, "lang", "") or "zh")
|
||||
user_text = build_scheduled_turn_instruction(prompt_text=prompt_text, mode=mode, lang=lang)
|
||||
viewer_username = resolve_scheduled_viewer_username(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=resolved.user_id,
|
||||
channel=resolved.channel,
|
||||
)
|
||||
payload = {
|
||||
"trace_id": trace_id,
|
||||
"run_id": agent_run_id,
|
||||
"session_id": resolved.session_id,
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": resolved.user_id,
|
||||
"viewer_username": viewer_username,
|
||||
"role": "member",
|
||||
"channel": resolved.channel if resolved.channel != "admin_chat" else "admin_chat",
|
||||
"lang": lang,
|
||||
"text": user_text,
|
||||
"prompt_text": prompt_text,
|
||||
"attachments": [],
|
||||
"metadata": {
|
||||
"scheduled_job_id": job_id,
|
||||
"scheduled_run_id": run.id,
|
||||
"interaction_mode": str(getattr(job, "interaction_mode", "") or "expert"),
|
||||
"selected_specialist": str(getattr(job, "specialist", "") or "generalist"),
|
||||
"scheduled_mode": mode,
|
||||
"scheduled_proactive": True,
|
||||
},
|
||||
"interaction_mode": str(getattr(job, "interaction_mode", "") or "expert"),
|
||||
"requested_specialist": str(getattr(job, "specialist", "") or "generalist"),
|
||||
"selected_specialist": str(getattr(job, "specialist", "") or "generalist"),
|
||||
"job_id": job_id,
|
||||
"run_id_scheduled": run.id,
|
||||
"delivery": delivery,
|
||||
"resolved_channel": resolved.channel,
|
||||
"resolved_chat_id": resolved.external_chat_id,
|
||||
"resolved_account_id": resolved.account_id,
|
||||
}
|
||||
worker_id = ensure_worker_started(store=store)
|
||||
task = store.oclaw_task_create(
|
||||
tenant_id=tenant_id,
|
||||
session_id=resolved.session_id,
|
||||
task_type="scheduled_turn",
|
||||
payload=payload,
|
||||
)
|
||||
store.scheduled_job_run_update(
|
||||
run_id=run.id,
|
||||
tenant_id=tenant_id,
|
||||
patch={
|
||||
"status": "running",
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"session_id": resolved.session_id,
|
||||
"oclaw_task_id": task.id,
|
||||
"run_id": agent_run_id,
|
||||
},
|
||||
)
|
||||
store.scheduled_job_reserve_next_run(job_id=job_id, tenant_id=tenant_id)
|
||||
pause_after = str(getattr(job, "schedule_kind", "") or "") == "once"
|
||||
return {
|
||||
"ok": True,
|
||||
"run_id": run.id,
|
||||
"task_id": task.id,
|
||||
"worker_id": worker_id,
|
||||
"pause_after": pause_after,
|
||||
}
|
||||
|
||||
|
||||
def scheduler_tick(store: Any) -> dict[str, Any]:
|
||||
due = store.scheduled_job_list_due(limit=20)
|
||||
triggered = 0
|
||||
errors: list[str] = []
|
||||
for job in due:
|
||||
try:
|
||||
out = enqueue_scheduled_job_run(store, job=job, mode="scheduled")
|
||||
if out.get("ok"):
|
||||
triggered += 1
|
||||
else:
|
||||
errors.append(str(out.get("error") or "enqueue_failed"))
|
||||
except Exception as exc:
|
||||
errors.append(f"{getattr(job, 'id', '')}: {type(exc).__name__}: {exc}")
|
||||
return {"ok": True, "due": len(due), "triggered": triggered, "errors": errors}
|
||||
|
||||
|
||||
def run_scheduled_job_now(store: Any, *, tenant_id: str, job_id: str) -> dict[str, Any]:
|
||||
job = store.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
|
||||
if not job:
|
||||
return {"ok": False, "error": "job_not_found"}
|
||||
if str(job.status or "") != "active":
|
||||
return {"ok": False, "error": "job_not_active"}
|
||||
return enqueue_scheduled_job_run(store, job=job, mode="manual")
|
||||
|
||||
|
||||
def _scheduler_loop(*, store: Any) -> None:
|
||||
global _RUNNING
|
||||
interval = _tick_interval_seconds()
|
||||
while _RUNNING:
|
||||
try:
|
||||
scheduler_tick(store)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def ensure_scheduler_started(*, store: Any) -> str:
|
||||
global _THREAD, _RUNNING
|
||||
with _LOCK:
|
||||
if _THREAD and _THREAD.is_alive():
|
||||
return _THREAD.name
|
||||
_RUNNING = True
|
||||
tid = f"oclaw-scheduler-{uuid.uuid4().hex[:8]}"
|
||||
t = threading.Thread(target=_scheduler_loop, kwargs={"store": store}, name=tid, daemon=True)
|
||||
t.start()
|
||||
_THREAD = t
|
||||
return tid
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ensure_scheduler_started",
|
||||
"enqueue_scheduled_job_run",
|
||||
"run_scheduled_job_now",
|
||||
"scheduler_tick",
|
||||
]
|
||||
262
runtime/scheduler/session_resolver.py
Normal file
262
runtime/scheduler/session_resolver.py
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from runtime.orchestration.group_ingest import session_user_key
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedSession:
|
||||
session_id: str
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
channel: str
|
||||
account_id: str
|
||||
external_chat_id: str
|
||||
external_user_id: str
|
||||
is_group: bool
|
||||
|
||||
|
||||
def _ensure_administrator_owner(store: Any, *, tenant_id: str) -> dict[str, Any] | None:
|
||||
user = store.get_user_by_username(tenant_id=tenant_id, username="administrator")
|
||||
if not user:
|
||||
try:
|
||||
from svc.config.passwords import load_expected_password
|
||||
except Exception:
|
||||
load_expected_password = None # type: ignore
|
||||
pwd = load_expected_password(store) if callable(load_expected_password) else None
|
||||
if not pwd:
|
||||
return None
|
||||
user = store.create_user_account(
|
||||
tenant_id=tenant_id,
|
||||
username="administrator",
|
||||
password_hash=hashlib.sha256(pwd.encode("utf-8")).hexdigest(),
|
||||
display_name="Administrator",
|
||||
role="owner",
|
||||
is_active=True,
|
||||
)
|
||||
user_id = str((user or {}).get("id") or "")
|
||||
if not user_id:
|
||||
return None
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id,
|
||||
"display_name": (user or {}).get("display_name") or "Administrator",
|
||||
"role": str((user or {}).get("role") or "owner"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_weixin_binding(store: Any, *, tenant_id: str) -> dict[str, Any] | None:
|
||||
owner = _ensure_administrator_owner(store, tenant_id=tenant_id)
|
||||
if not owner:
|
||||
return None
|
||||
user_id = str(owner.get("user_id") or "")
|
||||
rows = store.list_channel_identities_v2(
|
||||
tenant_id=tenant_id,
|
||||
channel="weixin",
|
||||
user_id=user_id,
|
||||
limit=20,
|
||||
)
|
||||
if not rows:
|
||||
for ch in ("wechat", "weixin"):
|
||||
rows = store.list_channel_identities_v2(
|
||||
tenant_id=tenant_id,
|
||||
channel=ch,
|
||||
user_id=user_id,
|
||||
limit=20,
|
||||
)
|
||||
if rows:
|
||||
break
|
||||
if not rows:
|
||||
return None
|
||||
row = rows[0]
|
||||
account_id = str(row.get("account_id") or "weixin-default").strip() or "weixin-default"
|
||||
external_user_id = str(row.get("external_user_id") or "").strip()
|
||||
if not external_user_id:
|
||||
return None
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id,
|
||||
"channel": str(row.get("channel") or "weixin"),
|
||||
"account_id": account_id,
|
||||
"external_user_id": external_user_id,
|
||||
"external_chat_id": external_user_id,
|
||||
"is_group": False,
|
||||
}
|
||||
|
||||
|
||||
def parse_delivery_json(raw: str) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(raw or "{}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def resolve_scheduled_viewer_username(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
channel: str,
|
||||
) -> str:
|
||||
"""Channel proactive jobs use the administrator model pool (same as gateway inbound)."""
|
||||
ch = str(channel or "").strip().lower()
|
||||
if ch in {"weixin", "wechat", "whatsapp"}:
|
||||
return "administrator"
|
||||
uid = str(user_id or "").strip()
|
||||
if uid:
|
||||
user = store.get_user_by_id(tenant_id=str(tenant_id or ""), user_id=uid)
|
||||
if isinstance(user, dict):
|
||||
uname = str(user.get("username") or "").strip()
|
||||
if uname:
|
||||
return uname
|
||||
return "administrator"
|
||||
|
||||
|
||||
def resolve_scheduled_session(
|
||||
store: Any,
|
||||
*,
|
||||
job: Any,
|
||||
created_by_user_id: str = "",
|
||||
) -> ResolvedSession:
|
||||
tenant_id = str(getattr(job, "tenant_id", "") or "")
|
||||
delivery = parse_delivery_json(str(getattr(job, "delivery_json", "") or "{}"))
|
||||
source_session_id = str(getattr(job, "source_session_id", "") or "").strip()
|
||||
job_name = str(getattr(job, "name", "") or "Scheduled task")
|
||||
|
||||
if source_session_id:
|
||||
sess = store.get_session_in_tenant(session_id=source_session_id, tenant_id=tenant_id)
|
||||
if sess:
|
||||
owner = store.get_ui_session_owner(session_id=source_session_id) or {}
|
||||
user_id = str(owner.get("user_id") or created_by_user_id or "").strip()
|
||||
if not user_id:
|
||||
admin = _ensure_administrator_owner(store, tenant_id=tenant_id)
|
||||
user_id = str((admin or {}).get("user_id") or "")
|
||||
channel_ctx = None
|
||||
lookup = getattr(store, "lookup_channel_session_by_session_id", None)
|
||||
if callable(lookup):
|
||||
channel_ctx = lookup(tenant_id=tenant_id, session_id=source_session_id)
|
||||
if isinstance(channel_ctx, dict) and str(channel_ctx.get("channel") or "").strip():
|
||||
ch = str(channel_ctx.get("channel") or "").strip().lower()
|
||||
account_id = str(channel_ctx.get("account_id") or "").strip()
|
||||
external_chat_id = str(channel_ctx.get("external_chat_id") or "").strip()
|
||||
external_user_id = str(channel_ctx.get("external_user_id") or "").strip()
|
||||
is_group = ch == "whatsapp" and external_chat_id.endswith("@g.us")
|
||||
return ResolvedSession(
|
||||
session_id=source_session_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
channel=ch,
|
||||
account_id=account_id or ("weixin-default" if ch in {"weixin", "wechat"} else ""),
|
||||
external_chat_id=external_chat_id,
|
||||
external_user_id=external_user_id,
|
||||
is_group=is_group,
|
||||
)
|
||||
return ResolvedSession(
|
||||
session_id=source_session_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
channel="admin_chat",
|
||||
account_id="",
|
||||
external_chat_id="",
|
||||
external_user_id="",
|
||||
is_group=False,
|
||||
)
|
||||
|
||||
wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {}
|
||||
wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
|
||||
wa_enabled = bool(wa.get("enabled")) and str(wa.get("target_type") or "none") != "none"
|
||||
wx_enabled = bool(wx.get("enabled", True))
|
||||
|
||||
if wa_enabled and str(wa.get("chat_id") or "").strip():
|
||||
chat_id = str(wa.get("chat_id") or "").strip()
|
||||
account_id = str(wa.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||||
target_type = str(wa.get("target_type") or "direct").strip().lower()
|
||||
is_group = target_type == "group" or chat_id.endswith("@g.us")
|
||||
external_user_id = session_user_key(is_group=is_group, external_user_id=chat_id.split("@", 1)[0])
|
||||
admin = _ensure_administrator_owner(store, tenant_id=tenant_id)
|
||||
user_id = str(created_by_user_id or (admin or {}).get("user_id") or "")
|
||||
session_id = store.get_or_create_channel_session_v2(
|
||||
tenant_id=tenant_id,
|
||||
channel="whatsapp",
|
||||
account_id=account_id,
|
||||
external_chat_id=chat_id,
|
||||
external_user_id=external_user_id,
|
||||
session_title=f"Scheduled · {job_name}",
|
||||
)
|
||||
if user_id:
|
||||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||
return ResolvedSession(
|
||||
session_id=session_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
channel="whatsapp",
|
||||
account_id=account_id,
|
||||
external_chat_id=chat_id,
|
||||
external_user_id=external_user_id,
|
||||
is_group=is_group,
|
||||
)
|
||||
|
||||
if wx_enabled:
|
||||
binding = resolve_weixin_binding(store, tenant_id=tenant_id)
|
||||
if not binding:
|
||||
raise RuntimeError("weixin_binding_missing")
|
||||
channel = str(binding.get("channel") or "weixin")
|
||||
account_id = str(binding.get("account_id") or "weixin-default")
|
||||
external_user_id = str(binding.get("external_user_id") or "")
|
||||
external_chat_id = str(binding.get("external_chat_id") or external_user_id)
|
||||
user_id = str(binding.get("user_id") or created_by_user_id or "")
|
||||
session_id = store.get_or_create_channel_session_v2(
|
||||
tenant_id=tenant_id,
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
external_chat_id=external_chat_id,
|
||||
external_user_id=external_user_id,
|
||||
session_title=f"Scheduled · {job_name}",
|
||||
)
|
||||
if user_id:
|
||||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||
return ResolvedSession(
|
||||
session_id=session_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
external_chat_id=external_chat_id,
|
||||
external_user_id=external_user_id,
|
||||
is_group=False,
|
||||
)
|
||||
|
||||
admin = _ensure_administrator_owner(store, tenant_id=tenant_id)
|
||||
user_id = str(created_by_user_id or (admin or {}).get("user_id") or "")
|
||||
if not user_id:
|
||||
raise RuntimeError("scheduled_session_owner_missing")
|
||||
sess = store.create_session_for_user(
|
||||
title=f"Scheduled · {job_name}",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
return ResolvedSession(
|
||||
session_id=str(sess.id),
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
channel="admin_chat",
|
||||
account_id="",
|
||||
external_chat_id="",
|
||||
external_user_id="",
|
||||
is_group=False,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ResolvedSession",
|
||||
"resolve_scheduled_session",
|
||||
"resolve_scheduled_viewer_username",
|
||||
"resolve_weixin_binding",
|
||||
"parse_delivery_json",
|
||||
]
|
||||
50
runtime/scheduler/turn_text.py
Normal file
50
runtime/scheduler/turn_text.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from __future__ import annotations
|
||||
|
||||
|
||||
def format_scheduled_user_reminder(prompt_text: str) -> str:
|
||||
body = str(prompt_text or "").strip()
|
||||
if not body:
|
||||
return ""
|
||||
if body.startswith("⏰"):
|
||||
return body
|
||||
return f"⏰ 提醒:{body}"
|
||||
|
||||
|
||||
def build_scheduled_turn_instruction(*, prompt_text: str, mode: str, lang: str) -> str:
|
||||
"""Internal LLM instruction for proactive scheduled reminders (not user-facing)."""
|
||||
intent = str(prompt_text or "").strip()
|
||||
_ = str(mode or "scheduled").strip()
|
||||
is_en = str(lang or "").lower().startswith("en")
|
||||
if is_en:
|
||||
return (
|
||||
"[Scheduled proactive reminder — internal instruction, not a user message]\n"
|
||||
f"Reminder intent: {intent}\n"
|
||||
"Write a short, friendly proactive reminder TO the user (second person). "
|
||||
"Do not say you received a reminder or that you will remind someone; speak directly to the user."
|
||||
)
|
||||
return (
|
||||
"【定时主动提醒·内部指令,不是用户发言】\n"
|
||||
f"提醒意图:{intent}\n"
|
||||
"请生成一条简短、自然、第二人称的主动提醒消息直接对用户说。"
|
||||
"不要写「收到提醒」「好的我来提醒用户」等元对话;不要假装用户刚说了话。"
|
||||
)
|
||||
|
||||
|
||||
def scheduled_turn_system_suffix(*, lang: str) -> str:
|
||||
is_en = str(lang or "").lower().startswith("en")
|
||||
if is_en:
|
||||
return (
|
||||
"\n\n[Scheduled job mode] You are sending a proactive reminder to the user. "
|
||||
"Reply with the reminder text only; do not role-play as the user."
|
||||
)
|
||||
return (
|
||||
"\n\n【定时任务模式】你正在主动向用户发送提醒。"
|
||||
"只输出提醒正文,不要扮演用户,不要写「收到/好的」等对话式应答。"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_scheduled_turn_instruction",
|
||||
"format_scheduled_user_reminder",
|
||||
"scheduled_turn_system_suffix",
|
||||
]
|
||||
90
runtime/scheduler/weixin_delivery.py
Normal file
90
runtime/scheduler/weixin_delivery.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from runtime.scheduler.session_resolver import resolve_weixin_binding
|
||||
|
||||
|
||||
def normalize_weixin_channel(channel: str) -> str:
|
||||
ch = str(channel or "wechat").strip().lower()
|
||||
return "wechat" if ch in {"wechat", "weixin"} else ch
|
||||
|
||||
|
||||
def resolve_weixin_delivery_target(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
delivery: dict[str, Any],
|
||||
resolved_channel: str,
|
||||
resolved_chat_id: str,
|
||||
resolved_account_id: str,
|
||||
) -> dict[str, Any]:
|
||||
wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
|
||||
channel = normalize_weixin_channel(
|
||||
str(resolved_channel or wx.get("channel") or "wechat")
|
||||
)
|
||||
chat_id = str(
|
||||
resolved_chat_id or wx.get("external_chat_id") or wx.get("external_user_id") or ""
|
||||
).strip()
|
||||
account_id = str(
|
||||
resolved_account_id or wx.get("account_id") or ""
|
||||
).strip()
|
||||
|
||||
sid = str(session_id or "").strip()
|
||||
lookup_sess = getattr(store, "lookup_channel_session_by_session_id", None)
|
||||
if sid and callable(lookup_sess):
|
||||
ctx = lookup_sess(tenant_id=str(tenant_id or ""), session_id=sid)
|
||||
if isinstance(ctx, dict):
|
||||
if not chat_id:
|
||||
chat_id = str(ctx.get("external_chat_id") or ctx.get("external_user_id") or "").strip()
|
||||
if not account_id or account_id == "weixin-default":
|
||||
account_id = str(ctx.get("account_id") or account_id or "").strip()
|
||||
if channel in {"wechat", "weixin"}:
|
||||
channel = normalize_weixin_channel(str(ctx.get("channel") or channel))
|
||||
|
||||
lookup_chat = getattr(store, "lookup_channel_session_by_chat_v2", None)
|
||||
if chat_id and callable(lookup_chat) and (not account_id or account_id == "weixin-default"):
|
||||
ctx = lookup_chat(
|
||||
tenant_id=str(tenant_id or ""),
|
||||
channel=channel,
|
||||
external_chat_id=chat_id,
|
||||
)
|
||||
if isinstance(ctx, dict):
|
||||
acct = str(ctx.get("account_id") or "").strip()
|
||||
if acct:
|
||||
account_id = acct
|
||||
|
||||
if not chat_id:
|
||||
binding = resolve_weixin_binding(store, tenant_id=str(tenant_id or ""))
|
||||
if isinstance(binding, dict):
|
||||
chat_id = str(
|
||||
binding.get("external_chat_id") or binding.get("external_user_id") or ""
|
||||
).strip()
|
||||
if not account_id or account_id == "weixin-default":
|
||||
account_id = str(binding.get("account_id") or account_id or "").strip()
|
||||
if channel in {"wechat", "weixin"}:
|
||||
channel = normalize_weixin_channel(str(binding.get("channel") or channel))
|
||||
|
||||
context_token = str(wx.get("context_token") or "").strip()
|
||||
getter = getattr(store, "get_channel_context_token_fuzzy", None)
|
||||
if callable(getter) and chat_id and not context_token:
|
||||
context_token = str(
|
||||
getter(
|
||||
tenant_id=str(tenant_id or ""),
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
external_chat_id=chat_id,
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
return {
|
||||
"channel": channel,
|
||||
"chat_id": chat_id,
|
||||
"account_id": account_id,
|
||||
"context_token": context_token,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["normalize_weixin_channel", "resolve_weixin_delivery_target"]
|
||||
184
runtime/scheduler/worker_turn.py
Normal file
184
runtime/scheduler/worker_turn.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
from runtime.orchestration.group_ingest import is_nonsend_channel_reply_text
|
||||
from runtime.scheduler.turn_text import format_scheduled_user_reminder
|
||||
|
||||
|
||||
def resolve_scheduled_outbound_text(*, payload: dict[str, Any], reply_text: str) -> str:
|
||||
text = str(reply_text or "").strip()
|
||||
if text and not is_nonsend_channel_reply_text(text):
|
||||
return text
|
||||
prompt = str(payload.get("prompt_text") or "").strip()
|
||||
if prompt:
|
||||
return format_scheduled_user_reminder(prompt)
|
||||
return ""
|
||||
|
||||
|
||||
def _persist_scheduled_assistant_reply(
|
||||
store: Any,
|
||||
*,
|
||||
session_id: str,
|
||||
turn_uuid: str,
|
||||
reply_text: str,
|
||||
) -> None:
|
||||
sid = str(session_id or "").strip()
|
||||
body = str(reply_text or "").strip()
|
||||
if not sid or not body:
|
||||
return
|
||||
tu = str(turn_uuid or "").strip()
|
||||
payload = {"scheduled_proactive": True}
|
||||
try:
|
||||
rows = store.get_messages(session_id=sid, limit=80)
|
||||
except Exception:
|
||||
rows = []
|
||||
if tu:
|
||||
for m in rows or []:
|
||||
if str(getattr(m, "role", "") or "").lower() != "assistant":
|
||||
continue
|
||||
if str(getattr(m, "turn_uuid", "") or "").strip() != tu:
|
||||
continue
|
||||
existing = str(getattr(m, "content", "") or "").strip()
|
||||
mid = int(getattr(m, "id", 0) or 0)
|
||||
updater = getattr(store, "update_message_content", None)
|
||||
if existing == body:
|
||||
if mid > 0 and callable(updater):
|
||||
merged = dict(payload)
|
||||
raw_ep = getattr(m, "event_payload", None)
|
||||
if isinstance(raw_ep, dict):
|
||||
merged = {**raw_ep, **merged}
|
||||
elif isinstance(raw_ep, str) and raw_ep.strip():
|
||||
try:
|
||||
parsed = json.loads(raw_ep)
|
||||
if isinstance(parsed, dict):
|
||||
merged = {**parsed, **merged}
|
||||
except Exception:
|
||||
pass
|
||||
updater(
|
||||
session_id=sid,
|
||||
message_id=mid,
|
||||
content=body,
|
||||
event_payload=merged,
|
||||
)
|
||||
return
|
||||
if not existing:
|
||||
if mid > 0 and callable(updater):
|
||||
updater(
|
||||
session_id=sid,
|
||||
message_id=mid,
|
||||
content=body,
|
||||
event_payload=payload,
|
||||
)
|
||||
return
|
||||
break
|
||||
for m in rows or []:
|
||||
if str(getattr(m, "role", "") or "").lower() != "assistant":
|
||||
continue
|
||||
if str(getattr(m, "content", "") or "").strip() == body:
|
||||
return
|
||||
try:
|
||||
store.add_message(
|
||||
session_id=sid,
|
||||
role="assistant",
|
||||
content=body,
|
||||
turn_uuid=tu or None,
|
||||
event_type="assistant_text",
|
||||
event_payload=payload,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def finalize_scheduled_turn_success(
|
||||
store: Any,
|
||||
*,
|
||||
task: Any,
|
||||
payload: dict[str, Any],
|
||||
base_result: dict[str, Any],
|
||||
) -> None:
|
||||
from runtime.scheduler.channel_delivery import deliver_scheduled_reply
|
||||
|
||||
tenant_id = str(payload.get("tenant_id") or "")
|
||||
job_id = str(payload.get("job_id") or "")
|
||||
scheduled_run_id = str(payload.get("run_id_scheduled") or "")
|
||||
reply_text = resolve_scheduled_outbound_text(payload=payload, reply_text=str(base_result.get("reply_text") or ""))
|
||||
delivery = payload.get("delivery") if isinstance(payload.get("delivery"), dict) else {}
|
||||
delivery_json = json.dumps(delivery, ensure_ascii=False)
|
||||
|
||||
job = store.scheduled_job_get(job_id=job_id, tenant_id=tenant_id) if job_id else None
|
||||
if job:
|
||||
delivery_json = str(getattr(job, "delivery_json", "") or delivery_json)
|
||||
|
||||
_persist_scheduled_assistant_reply(
|
||||
store,
|
||||
session_id=str(payload.get("session_id") or ""),
|
||||
turn_uuid=str(base_result.get("turn_uuid") or payload.get("run_id") or ""),
|
||||
reply_text=reply_text,
|
||||
)
|
||||
delivery_status = deliver_scheduled_reply(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
reply_text=reply_text,
|
||||
delivery_json=delivery_json,
|
||||
resolved_channel=str(payload.get("resolved_channel") or ""),
|
||||
resolved_chat_id=str(payload.get("resolved_chat_id") or ""),
|
||||
resolved_account_id=str(payload.get("resolved_account_id") or ""),
|
||||
session_id=str(payload.get("session_id") or ""),
|
||||
)
|
||||
if scheduled_run_id:
|
||||
store.scheduled_job_run_update(
|
||||
run_id=scheduled_run_id,
|
||||
tenant_id=tenant_id,
|
||||
patch={
|
||||
"status": "success" if delivery_status.get("ok") else "failed",
|
||||
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||||
"reply_text": reply_text,
|
||||
"delivery_status": delivery_status,
|
||||
"session_id": str(payload.get("session_id") or ""),
|
||||
},
|
||||
)
|
||||
if job_id and job:
|
||||
pause_after = str(getattr(job, "schedule_kind", "") or "") == "once"
|
||||
store.scheduled_job_mark_run(
|
||||
job_id=job_id,
|
||||
tenant_id=tenant_id,
|
||||
last_run_status="success" if delivery_status.get("ok") else "failed",
|
||||
pause_after=pause_after,
|
||||
)
|
||||
|
||||
|
||||
def finalize_scheduled_turn_failure(
|
||||
store: Any,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
error: str,
|
||||
) -> None:
|
||||
tenant_id = str(payload.get("tenant_id") or "")
|
||||
job_id = str(payload.get("job_id") or "")
|
||||
scheduled_run_id = str(payload.get("run_id_scheduled") or "")
|
||||
if scheduled_run_id:
|
||||
store.scheduled_job_run_update(
|
||||
run_id=scheduled_run_id,
|
||||
tenant_id=tenant_id,
|
||||
patch={
|
||||
"status": "failed",
|
||||
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||||
"error": str(error or "")[:500],
|
||||
},
|
||||
)
|
||||
if job_id:
|
||||
job = store.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
|
||||
pause_after = bool(job and str(getattr(job, "schedule_kind", "") or "") == "once")
|
||||
store.scheduled_job_mark_run(
|
||||
job_id=job_id,
|
||||
tenant_id=tenant_id,
|
||||
last_run_status="failed",
|
||||
pause_after=pause_after,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["finalize_scheduled_turn_failure", "finalize_scheduled_turn_success"]
|
||||
70
runtime/tools/context_inject.py
Normal file
70
runtime/tools/context_inject.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_CONTEXT_SCOPED_TOOL_PREFIXES = ("schedule_", "todo_")
|
||||
_CONTEXT_SCOPED_TOOL_NAMES = frozenset(
|
||||
{
|
||||
"kb_add",
|
||||
"kb_search",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _session_owner(store: Any, session_id: str) -> tuple[str, str]:
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return "", ""
|
||||
try:
|
||||
owner = store.get_ui_session_owner(session_id=sid) or {}
|
||||
except Exception:
|
||||
owner = {}
|
||||
if isinstance(owner, dict):
|
||||
tid = str(owner.get("tenant_id") or "").strip()
|
||||
uid = str(owner.get("user_id") or "").strip()
|
||||
if tid and uid:
|
||||
return tid, uid
|
||||
return "", ""
|
||||
|
||||
|
||||
def enrich_tool_arguments(
|
||||
*,
|
||||
store: Any,
|
||||
session_id: str,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Inject tenant/user/session context for productivity tools (WeChat, WhatsApp, etc.)."""
|
||||
merged = dict(arguments or {})
|
||||
name = str(tool_name or "").strip()
|
||||
if not name:
|
||||
return merged
|
||||
scoped = name in _CONTEXT_SCOPED_TOOL_NAMES or any(
|
||||
name.startswith(p) for p in _CONTEXT_SCOPED_TOOL_PREFIXES
|
||||
)
|
||||
if not scoped:
|
||||
return merged
|
||||
|
||||
tenant_id = str(path_policy_tenant_id or merged.get("tenant_id") or "").strip()
|
||||
user_id = str(path_policy_user_id or merged.get("owner_user_id") or merged.get("user_id") or "").strip()
|
||||
if not tenant_id or not user_id:
|
||||
o_tid, o_uid = _session_owner(store, session_id)
|
||||
tenant_id = tenant_id or o_tid
|
||||
user_id = user_id or o_uid
|
||||
|
||||
if tenant_id:
|
||||
merged["tenant_id"] = tenant_id
|
||||
if user_id:
|
||||
if name in _CONTEXT_SCOPED_TOOL_NAMES:
|
||||
merged["user_id"] = user_id
|
||||
else:
|
||||
merged["owner_user_id"] = user_id
|
||||
sid = str(session_id or merged.get("session_id") or "").strip()
|
||||
if sid:
|
||||
merged["session_id"] = sid
|
||||
return merged
|
||||
|
||||
|
||||
__all__ = ["enrich_tool_arguments"]
|
||||
334
runtime/tools/experts/productivity/schedule_tools.py
Normal file
334
runtime/tools/experts/productivity/schedule_tools.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from runtime.scheduler.cron_service import build_delivery_for_session
|
||||
from runtime.scheduler.expressions import normalize_schedule_kind
|
||||
from runtime.scheduler.service import run_scheduled_job_now
|
||||
from runtime.types import normalize_interaction_mode, normalize_requested_specialist
|
||||
from svc.persistence.assistant_store import get_assistant_store
|
||||
from runtime.tools.base import ToolSpec
|
||||
from runtime.tools.context_inject import enrich_tool_arguments
|
||||
|
||||
|
||||
def _require(s: str, name: str) -> str:
|
||||
v = (s or "").strip()
|
||||
if not v:
|
||||
raise ValueError(f"{name} is required")
|
||||
return v
|
||||
|
||||
|
||||
def _parse_delivery_arg(raw: Any) -> dict[str, Any] | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def schedule_create_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
store = get_assistant_store()
|
||||
args = enrich_tool_arguments(
|
||||
store=store,
|
||||
session_id=str(args.get("session_id") or ""),
|
||||
tool_name="schedule_create",
|
||||
arguments=args,
|
||||
)
|
||||
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
|
||||
owner_user_id = _require(
|
||||
str(args.get("owner_user_id") or args.get("user_id") or ""),
|
||||
"owner_user_id",
|
||||
)
|
||||
name = _require(str(args.get("name") or ""), "name")
|
||||
prompt_text = _require(str(args.get("prompt_text") or ""), "prompt_text")
|
||||
schedule_kind = normalize_schedule_kind(str(args.get("schedule_kind") or "cron"))
|
||||
schedule_expr = _require(str(args.get("schedule_expr") or ""), "schedule_expr")
|
||||
delivery = _parse_delivery_arg(args.get("delivery"))
|
||||
if delivery is None:
|
||||
delivery = build_delivery_for_session(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
session_id=str(args.get("session_id") or ""),
|
||||
whatsapp_chat_id=str(args.get("whatsapp_chat_id") or ""),
|
||||
)
|
||||
interaction_mode = normalize_interaction_mode(
|
||||
str(args.get("interaction_mode") or "expert")
|
||||
)
|
||||
specialist = normalize_requested_specialist(
|
||||
str(args.get("specialist") or args.get("selected_specialist") or "generalist")
|
||||
)
|
||||
row = store.scheduled_job_create(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
prompt_text=prompt_text,
|
||||
schedule_kind=schedule_kind,
|
||||
schedule_expr=schedule_expr,
|
||||
timezone_name=str(args.get("timezone") or "Asia/Shanghai"),
|
||||
description=str(args.get("description") or ""),
|
||||
interaction_mode=interaction_mode,
|
||||
specialist=specialist,
|
||||
lang=str(args.get("lang") or "zh"),
|
||||
delivery=delivery,
|
||||
source_session_id=str(args.get("session_id") or "").strip() or None,
|
||||
created_by_user_id=owner_user_id,
|
||||
source="chat",
|
||||
)
|
||||
return {"ok": True, "job": store.scheduled_job_to_dict(row)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
return ToolSpec(
|
||||
name="schedule_create",
|
||||
description="Create a scheduled job (cron, once, or interval). Delivery follows the current chat channel (WhatsApp vs WeChat) unless delivery is set explicitly.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tenant_id": {"type": "string", "description": "Auto-filled from session; do not guess."},
|
||||
"owner_user_id": {"type": "string", "description": "Auto-filled from session."},
|
||||
"session_id": {"type": "string", "description": "Auto-filled from session."},
|
||||
"name": {"type": "string"},
|
||||
"prompt_text": {"type": "string"},
|
||||
"schedule_kind": {"type": "string", "enum": ["cron", "once", "interval"]},
|
||||
"schedule_expr": {"type": "string"},
|
||||
"timezone": {"type": "string", "default": "Asia/Shanghai"},
|
||||
"interaction_mode": {"type": "string"},
|
||||
"specialist": {"type": "string"},
|
||||
"selected_specialist": {"type": "string"},
|
||||
"lang": {"type": "string"},
|
||||
"whatsapp_chat_id": {"type": "string"},
|
||||
"delivery": {"type": "object"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["name", "prompt_text", "schedule_kind", "schedule_expr"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=handler,
|
||||
tags=frozenset({"productivity", "write", "schedule"}),
|
||||
)
|
||||
|
||||
|
||||
def schedule_list_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
store = get_assistant_store()
|
||||
args = enrich_tool_arguments(
|
||||
store=store,
|
||||
session_id=str(args.get("session_id") or ""),
|
||||
tool_name="schedule_list",
|
||||
arguments=args,
|
||||
)
|
||||
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
|
||||
status = str(args.get("status") or "").strip() or None
|
||||
limit = int(args.get("limit") or 50)
|
||||
rows = store.scheduled_job_list(tenant_id=tenant_id, status=status, limit=limit)
|
||||
return {"ok": True, "items": [store.scheduled_job_to_dict(r) for r in rows]}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
return ToolSpec(
|
||||
name="schedule_list",
|
||||
description="List scheduled jobs for a tenant.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tenant_id": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 50},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=handler,
|
||||
tags=frozenset({"productivity", "schedule"}),
|
||||
)
|
||||
|
||||
|
||||
def _scoped_args(store: Any, tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
return enrich_tool_arguments(
|
||||
store=store,
|
||||
session_id=str(args.get("session_id") or ""),
|
||||
tool_name=tool_name,
|
||||
arguments=args,
|
||||
)
|
||||
|
||||
|
||||
def schedule_update_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
store = get_assistant_store()
|
||||
args = _scoped_args(store, "schedule_update", args)
|
||||
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
|
||||
job_id = _require(str(args.get("job_id") or ""), "job_id")
|
||||
patch: dict[str, Any] = {}
|
||||
for key in (
|
||||
"name",
|
||||
"description",
|
||||
"prompt_text",
|
||||
"schedule_kind",
|
||||
"schedule_expr",
|
||||
"timezone",
|
||||
"interaction_mode",
|
||||
"specialist",
|
||||
"lang",
|
||||
):
|
||||
if key in args and args.get(key) is not None:
|
||||
patch[key] = args.get(key)
|
||||
delivery = _parse_delivery_arg(args.get("delivery"))
|
||||
if delivery is not None:
|
||||
patch["delivery"] = delivery
|
||||
row = store.scheduled_job_update(tenant_id=tenant_id, job_id=job_id, patch=patch)
|
||||
if not row:
|
||||
return {"ok": False, "error": "job_not_found"}
|
||||
return {"ok": True, "job": store.scheduled_job_to_dict(row)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
return ToolSpec(
|
||||
name="schedule_update",
|
||||
description="Update a scheduled job.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tenant_id": {"type": "string"},
|
||||
"job_id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"prompt_text": {"type": "string"},
|
||||
"schedule_kind": {"type": "string"},
|
||||
"schedule_expr": {"type": "string"},
|
||||
"timezone": {"type": "string"},
|
||||
"interaction_mode": {"type": "string"},
|
||||
"specialist": {"type": "string"},
|
||||
"lang": {"type": "string"},
|
||||
"delivery": {"type": "object"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["job_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=handler,
|
||||
tags=frozenset({"productivity", "write", "schedule"}),
|
||||
)
|
||||
|
||||
|
||||
def schedule_pause_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
store = get_assistant_store()
|
||||
args = _scoped_args(store, "schedule_pause", args)
|
||||
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
|
||||
job_id = _require(str(args.get("job_id") or ""), "job_id")
|
||||
ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=job_id, status="paused")
|
||||
return {"ok": bool(ok), "job_id": job_id, "status": "paused"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
return ToolSpec(
|
||||
name="schedule_pause",
|
||||
description="Pause a scheduled job.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
|
||||
"required": ["job_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=handler,
|
||||
tags=frozenset({"productivity", "write", "schedule"}),
|
||||
)
|
||||
|
||||
|
||||
def schedule_resume_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
store = get_assistant_store()
|
||||
args = _scoped_args(store, "schedule_resume", args)
|
||||
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
|
||||
job_id = _require(str(args.get("job_id") or ""), "job_id")
|
||||
ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=job_id, status="active")
|
||||
return {"ok": bool(ok), "job_id": job_id, "status": "active"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
return ToolSpec(
|
||||
name="schedule_resume",
|
||||
description="Resume a paused scheduled job.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
|
||||
"required": ["job_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=handler,
|
||||
tags=frozenset({"productivity", "write", "schedule"}),
|
||||
)
|
||||
|
||||
|
||||
def schedule_delete_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
store = get_assistant_store()
|
||||
args = _scoped_args(store, "schedule_delete", args)
|
||||
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
|
||||
job_id = _require(str(args.get("job_id") or ""), "job_id")
|
||||
ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=job_id)
|
||||
return {"ok": bool(ok), "job_id": job_id}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
return ToolSpec(
|
||||
name="schedule_delete",
|
||||
description="Delete (soft) a scheduled job.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
|
||||
"required": ["job_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=handler,
|
||||
tags=frozenset({"productivity", "write", "schedule"}),
|
||||
)
|
||||
|
||||
|
||||
def schedule_run_now_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
store = get_assistant_store()
|
||||
args = _scoped_args(store, "schedule_run_now", args)
|
||||
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
|
||||
job_id = _require(str(args.get("job_id") or ""), "job_id")
|
||||
out = run_scheduled_job_now(store, tenant_id=tenant_id, job_id=job_id)
|
||||
return out
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
return ToolSpec(
|
||||
name="schedule_run_now",
|
||||
description="Trigger a scheduled job immediately.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
|
||||
"required": ["job_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=handler,
|
||||
tags=frozenset({"productivity", "write", "schedule"}),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"schedule_create_tool",
|
||||
"schedule_list_tool",
|
||||
"schedule_update_tool",
|
||||
"schedule_pause_tool",
|
||||
"schedule_resume_tool",
|
||||
"schedule_delete_tool",
|
||||
"schedule_run_now_tool",
|
||||
]
|
||||
|
|
@ -10,6 +10,17 @@ except Exception: # pragma: no cover
|
|||
validators = None
|
||||
|
||||
|
||||
def filter_arguments_to_schema(parameters: dict[str, Any], arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Drop keys not declared in tool schema when additionalProperties is false."""
|
||||
if not isinstance(arguments, dict):
|
||||
return {}
|
||||
schema = parameters or {}
|
||||
props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
||||
if schema.get("additionalProperties") is False and props:
|
||||
return {k: v for k, v in arguments.items() if k in props}
|
||||
return dict(arguments)
|
||||
|
||||
|
||||
def validate_tool_arguments(parameters: dict[str, Any], arguments: dict[str, Any]) -> tuple[bool, str | None]:
|
||||
"""校验模型给出的 arguments 是否符合工具的 JSON Schema(OpenAI function parameters)。"""
|
||||
if not isinstance(arguments, dict):
|
||||
|
|
@ -33,5 +44,5 @@ def validate_tool_arguments(parameters: dict[str, Any], arguments: dict[str, Any
|
|||
return True, None
|
||||
|
||||
|
||||
__all__ = ["validate_tool_arguments"]
|
||||
__all__ = ["filter_arguments_to_schema", "validate_tool_arguments"]
|
||||
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
|
|||
tenant_id = str(payload.get("tenant_id") or "")
|
||||
user_id = str(payload.get("user_id") or "")
|
||||
viewer_username = str(payload.get("viewer_username") or "")
|
||||
is_scheduled_turn = str(task.task_type or "") == "scheduled_turn"
|
||||
model_profile_id = str(payload.get("model_profile_id") or "") or None
|
||||
interaction_mode = normalize_interaction_mode(
|
||||
str(payload.get("interaction_mode") or metadata.get("interaction_mode") or "")
|
||||
|
|
@ -284,6 +285,10 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
|
|||
system_prompt = ""
|
||||
if not system_prompt:
|
||||
system_prompt = str(getattr(executor, "system_prompt", "") or "")
|
||||
if is_scheduled_turn:
|
||||
from runtime.scheduler.turn_text import scheduled_turn_system_suffix
|
||||
|
||||
system_prompt = str(system_prompt or "") + scheduled_turn_system_suffix(lang=lang)
|
||||
|
||||
max_messages = int(store.get_setting("AIA_TURN_MAX_CONTEXT_MESSAGES") or 80)
|
||||
max_tool_rounds = int(store.get_setting("AIA_TURN_MAX_TOOL_ROUNDS") or 100)
|
||||
|
|
@ -299,18 +304,19 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
|
|||
attachments=list(attachments or []),
|
||||
metadata=dict(metadata or {}),
|
||||
)
|
||||
_maybe_rename_from_first_user_message(
|
||||
store=store,
|
||||
session_id=session_id,
|
||||
user_text=user_text,
|
||||
attachments=list(attachments or []),
|
||||
)
|
||||
_maybe_generate_title_on_third_round(store=store, msg=msg, model=getattr(executor, "model", None))
|
||||
if not is_scheduled_turn:
|
||||
_maybe_rename_from_first_user_message(
|
||||
store=store,
|
||||
session_id=session_id,
|
||||
user_text=user_text,
|
||||
attachments=list(attachments or []),
|
||||
)
|
||||
_maybe_generate_title_on_third_round(store=store, msg=msg, model=getattr(executor, "model", None))
|
||||
run_out = run_agent_core(
|
||||
store=store,
|
||||
data=AgentCoreRunInput(
|
||||
msg=msg,
|
||||
persisted_user_text=str(user_text or ""),
|
||||
persisted_user_text="" if is_scheduled_turn else str(user_text or ""),
|
||||
lang=lang,
|
||||
system_prompt=system_prompt,
|
||||
model=executor.model,
|
||||
|
|
@ -327,10 +333,12 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
|
|||
oclaw_worker_id=worker_id,
|
||||
skill_binding_role=skill_binding_role,
|
||||
wire_policy_role=wire_policy_role,
|
||||
persist_user_message=not is_scheduled_turn,
|
||||
),
|
||||
)
|
||||
base_result = {
|
||||
"run_id": str(run_out.run_id or ""),
|
||||
"turn_uuid": str(run_out.outcome.turn_uuid or ""),
|
||||
"reply_text": run_out.outcome.final_text,
|
||||
"tool_trace_count": len(run_out.outcome.tool_traces),
|
||||
"relay_pointer_count": int(payload.get("relay_pointer_count") or 0),
|
||||
|
|
@ -344,6 +352,15 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
|
|||
relay_envelope=relay_share_envelope,
|
||||
)
|
||||
)
|
||||
if str(task.task_type or "") == "scheduled_turn":
|
||||
from runtime.scheduler.worker_turn import finalize_scheduled_turn_success
|
||||
|
||||
finalize_scheduled_turn_success(
|
||||
store=store,
|
||||
task=task,
|
||||
payload=payload,
|
||||
base_result=base_result,
|
||||
)
|
||||
store.oclaw_task_finish(task_id=task.id, result=base_result)
|
||||
if trace_id:
|
||||
trace_payload = {
|
||||
|
|
@ -369,6 +386,10 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
|
|||
payload=trace_payload,
|
||||
)
|
||||
except Exception as exc:
|
||||
if str(getattr(task, "task_type", "") or "") == "scheduled_turn":
|
||||
from runtime.scheduler.worker_turn import finalize_scheduled_turn_failure
|
||||
|
||||
finalize_scheduled_turn_failure(store=store, payload=payload, error=str(exc))
|
||||
store.oclaw_task_fail(task_id=task.id, error=str(exc), result={"ok": False})
|
||||
try:
|
||||
if trace_id:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue