mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +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
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"]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue