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:
oliver 2026-06-26 15:56:10 +08:00
parent 5336f8835f
commit 47813f6a94
45 changed files with 4956 additions and 43 deletions

View file

@ -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),

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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(),
});

View file

@ -0,0 +1,3 @@
from __future__ import annotations
__all__ = []

View 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",
]

View 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"]

View 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"]

View 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",
]

View 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",
]

View 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",
]

View 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"]

View 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"]

View 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"]

View 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",
]

View file

@ -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"]

View file

@ -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: