mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 05:20:44 +08:00
feat(scheduler): workflow recipes with confirm-before-create
Store self-contained playbook recipes on scheduled jobs, add schedule_propose draft gate, and run playbook instructions at fire time instead of only short reminders. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ad5d1db3c6
commit
d036190195
10 changed files with 989 additions and 49 deletions
|
|
@ -1,17 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from runtime.scheduler.cron_service import build_delivery_for_session
|
||||
from runtime.scheduler.whatsapp_mentions import merge_whatsapp_mention_jids, merge_whatsapp_mention_names
|
||||
from runtime.scheduler.expressions import normalize_schedule_kind
|
||||
from runtime.scheduler.system_timezone import default_system_timezone
|
||||
from runtime.scheduler.recipe import (
|
||||
looks_like_complex_schedule_prompt,
|
||||
normalize_recipe,
|
||||
parse_recipe_arg,
|
||||
preview_markdown,
|
||||
prompt_summary_from_recipe,
|
||||
recipe_has_playbook,
|
||||
recipe_missing_fields,
|
||||
)
|
||||
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.scheduler.system_timezone import default_system_timezone
|
||||
from runtime.scheduler.whatsapp_mentions import merge_whatsapp_mention_jids, merge_whatsapp_mention_names
|
||||
from runtime.tools.base import ToolSpec
|
||||
from runtime.tools.context_inject import enrich_tool_arguments
|
||||
from runtime.types import normalize_interaction_mode, normalize_requested_specialist
|
||||
from svc.persistence.assistant_store import get_assistant_store
|
||||
|
||||
|
||||
def _require(s: str, name: str) -> str:
|
||||
|
|
@ -35,6 +45,124 @@ def _parse_delivery_arg(raw: Any) -> dict[str, Any] | None:
|
|||
return None
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
_RECIPE_PARAM = {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Self-contained workflow recipe (playbook). Required for complex/multi-step jobs. "
|
||||
"Must be understandable WITHOUT prior chat context: no '继续刚才/按上面'; "
|
||||
"put concrete paths, commands, time windows, and params in goal/steps/inputs.constants. "
|
||||
"Fields: goal, steps (>=2), success_criteria, optional constraints/inputs/output/source."
|
||||
),
|
||||
"properties": {
|
||||
"version": {"type": "integer"},
|
||||
"goal": {"type": "string"},
|
||||
"steps": {"type": "array", "items": {"type": "string"}},
|
||||
"constraints": {"type": "array", "items": {"type": "string"}},
|
||||
"success_criteria": {"type": "array", "items": {"type": "string"}},
|
||||
"inputs": {"type": "object"},
|
||||
"output": {"type": "object"},
|
||||
"source": {"type": "object"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def schedule_propose_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
store = get_assistant_store()
|
||||
args = _scoped_args(store, "schedule_propose", args)
|
||||
name = str(args.get("name") or "").strip() or "Scheduled workflow"
|
||||
schedule_kind = normalize_schedule_kind(str(args.get("schedule_kind") or "cron"))
|
||||
schedule_expr = _require(str(args.get("schedule_expr") or ""), "schedule_expr")
|
||||
timezone_name = str(args.get("timezone") or default_system_timezone()).strip() or default_system_timezone()
|
||||
lang = str(args.get("lang") or "zh")
|
||||
session_id = str(args.get("session_id") or "").strip()
|
||||
recipe = normalize_recipe(parse_recipe_arg(args.get("recipe")))
|
||||
if session_id and not str((recipe.get("source") or {}).get("session_id") or "").strip():
|
||||
recipe["source"]["session_id"] = session_id
|
||||
if not str((recipe.get("source") or {}).get("compiled_at") or "").strip():
|
||||
recipe["source"]["compiled_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
missing = recipe_missing_fields(recipe)
|
||||
if missing:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "recipe_incomplete",
|
||||
"missing_fields": missing,
|
||||
"hint": (
|
||||
"Fill goal, at least 2 steps, and success_criteria from the recent conversation, "
|
||||
"then call schedule_propose again. Do not create the job yet."
|
||||
),
|
||||
"recipe": recipe,
|
||||
}
|
||||
|
||||
preview = preview_markdown(
|
||||
name=name,
|
||||
schedule_kind=schedule_kind,
|
||||
schedule_expr=schedule_expr,
|
||||
timezone_name=timezone_name,
|
||||
recipe=recipe,
|
||||
lang=lang,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"draft": True,
|
||||
"name": name,
|
||||
"schedule_kind": schedule_kind,
|
||||
"schedule_expr": schedule_expr,
|
||||
"timezone": timezone_name,
|
||||
"recipe": recipe,
|
||||
"prompt_text": prompt_summary_from_recipe(recipe, fallback=name),
|
||||
"preview_markdown": preview,
|
||||
"next_step": (
|
||||
"Show preview_markdown to the user. After they confirm (or request edits), "
|
||||
"call schedule_create with the same recipe and schedule fields."
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
return ToolSpec(
|
||||
name="schedule_propose",
|
||||
description=(
|
||||
"Draft a scheduled workflow recipe WITHOUT creating the job. "
|
||||
"Use when the user wants to schedule a multi-step task they just guided "
|
||||
"(e.g. '做成定时/每周跑刚才那套'). "
|
||||
"CRITICAL: the recipe must be self-contained — at fire time there is no prior chat; "
|
||||
"a new LLM must understand the task from recipe alone (no '继续刚才', embed paths/params in steps/constants). "
|
||||
"Compile goal/steps(>=2)/success_criteria/constraints from the conversation into `recipe`, "
|
||||
"then show preview_markdown and wait for confirmation before schedule_create."
|
||||
),
|
||||
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"},
|
||||
"recipe": _RECIPE_PARAM,
|
||||
"schedule_kind": {"type": "string", "enum": ["cron", "once", "interval"]},
|
||||
"schedule_expr": {"type": "string"},
|
||||
"timezone": {"type": "string"},
|
||||
"lang": {"type": "string"},
|
||||
},
|
||||
"required": ["recipe", "schedule_kind", "schedule_expr"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=handler,
|
||||
tags=frozenset({"productivity", "schedule"}),
|
||||
)
|
||||
|
||||
|
||||
def schedule_create_tool() -> ToolSpec:
|
||||
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
|
|
@ -51,7 +179,37 @@ def schedule_create_tool() -> ToolSpec:
|
|||
"owner_user_id",
|
||||
)
|
||||
name = _require(str(args.get("name") or ""), "name")
|
||||
prompt_text = _require(str(args.get("prompt_text") or ""), "prompt_text")
|
||||
prompt_text = str(args.get("prompt_text") or "").strip()
|
||||
recipe_raw = parse_recipe_arg(args.get("recipe"))
|
||||
recipe = normalize_recipe(recipe_raw) if recipe_raw is not None else {}
|
||||
if recipe_has_playbook(recipe):
|
||||
prompt_text = prompt_summary_from_recipe(recipe, fallback=prompt_text or name)
|
||||
if not prompt_text:
|
||||
raise ValueError("prompt_text is required")
|
||||
|
||||
needs_recipe = looks_like_complex_schedule_prompt(prompt_text, recipe=recipe)
|
||||
if needs_recipe and not recipe_has_playbook(recipe):
|
||||
missing = recipe_missing_fields(recipe) or ["goal", "steps", "success_criteria"]
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "recipe_required",
|
||||
"missing_fields": missing,
|
||||
"hint": (
|
||||
"This looks like a complex/multi-step job. Call schedule_propose first, "
|
||||
"show the draft to the user, then schedule_create with a full recipe "
|
||||
"(goal + >=2 steps + success_criteria). Do not store vague prompts like "
|
||||
"'继续刚才那个'."
|
||||
),
|
||||
}
|
||||
if recipe and not recipe_has_playbook(recipe) and recipe_missing_fields(recipe):
|
||||
# Explicit but incomplete recipe → reject rather than silently drop.
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "recipe_incomplete",
|
||||
"missing_fields": recipe_missing_fields(recipe),
|
||||
"hint": "Complete the recipe or omit it for a simple reminder.",
|
||||
}
|
||||
|
||||
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"))
|
||||
|
|
@ -76,6 +234,15 @@ def schedule_create_tool() -> ToolSpec:
|
|||
specialist = normalize_requested_specialist(
|
||||
str(args.get("specialist") or args.get("selected_specialist") or "generalist")
|
||||
)
|
||||
session_id = str(args.get("session_id") or "").strip() or None
|
||||
if recipe_has_playbook(recipe):
|
||||
if session_id and not str((recipe.get("source") or {}).get("session_id") or "").strip():
|
||||
recipe["source"]["session_id"] = session_id
|
||||
if not str((recipe.get("source") or {}).get("compiled_at") or "").strip():
|
||||
recipe["source"]["compiled_at"] = datetime.now(timezone.utc).isoformat()
|
||||
else:
|
||||
recipe = {}
|
||||
|
||||
row = store.scheduled_job_create(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
|
|
@ -88,7 +255,8 @@ def schedule_create_tool() -> ToolSpec:
|
|||
specialist=specialist,
|
||||
lang=str(args.get("lang") or "zh"),
|
||||
delivery=delivery,
|
||||
source_session_id=str(args.get("session_id") or "").strip() or None,
|
||||
recipe=recipe,
|
||||
source_session_id=session_id,
|
||||
created_by_user_id=owner_user_id,
|
||||
source="chat",
|
||||
)
|
||||
|
|
@ -98,7 +266,14 @@ def schedule_create_tool() -> ToolSpec:
|
|||
|
||||
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. For WhatsApp group @mentions, set whatsapp_mention_jids with explicit JIDs.",
|
||||
description=(
|
||||
"Create a scheduled job after the user confirmed the draft. "
|
||||
"Simple reminders may use prompt_text only. "
|
||||
"Complex / multi-step / '刚才那件事做成定时' jobs MUST include a self-contained recipe "
|
||||
"(goal + >=2 concrete steps + success_criteria; no chat-dependent phrasing); "
|
||||
"call schedule_propose and get user confirmation first. "
|
||||
"Delivery follows the current chat channel unless delivery is set explicitly."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -106,7 +281,11 @@ def schedule_create_tool() -> ToolSpec:
|
|||
"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"},
|
||||
"prompt_text": {
|
||||
"type": "string",
|
||||
"description": "Short summary / reminder intent. For playbooks, prefer recipe.goal.",
|
||||
},
|
||||
"recipe": _RECIPE_PARAM,
|
||||
"schedule_kind": {"type": "string", "enum": ["cron", "once", "interval"]},
|
||||
"schedule_expr": {"type": "string"},
|
||||
"timezone": {"type": "string", "description": "IANA timezone; defaults to the host system timezone."},
|
||||
|
|
@ -120,6 +299,10 @@ def schedule_create_tool() -> ToolSpec:
|
|||
"items": {"type": "string"},
|
||||
"description": "WhatsApp JIDs to @mention on delivery (e.g. 628...@s.whatsapp.net). Stored in delivery.whatsapp.mention_jids.",
|
||||
},
|
||||
"whatsapp_mention_names": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"delivery": {"type": "object"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
|
|
@ -167,15 +350,6 @@ def schedule_list_tool() -> ToolSpec:
|
|||
)
|
||||
|
||||
|
||||
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:
|
||||
|
|
@ -200,6 +374,21 @@ def schedule_update_tool() -> ToolSpec:
|
|||
delivery = _parse_delivery_arg(args.get("delivery"))
|
||||
if delivery is not None:
|
||||
patch["delivery"] = delivery
|
||||
recipe_raw = parse_recipe_arg(args.get("recipe"))
|
||||
if recipe_raw is not None:
|
||||
recipe = normalize_recipe(recipe_raw)
|
||||
if recipe_has_playbook(recipe):
|
||||
patch["recipe"] = recipe
|
||||
if "prompt_text" not in patch:
|
||||
patch["prompt_text"] = prompt_summary_from_recipe(recipe)
|
||||
elif recipe_missing_fields(recipe):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "recipe_incomplete",
|
||||
"missing_fields": recipe_missing_fields(recipe),
|
||||
}
|
||||
else:
|
||||
patch["recipe"] = {}
|
||||
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"}
|
||||
|
|
@ -209,7 +398,7 @@ def schedule_update_tool() -> ToolSpec:
|
|||
|
||||
return ToolSpec(
|
||||
name="schedule_update",
|
||||
description="Update a scheduled job.",
|
||||
description="Update a scheduled job (including recipe playbook fields).",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -217,6 +406,7 @@ def schedule_update_tool() -> ToolSpec:
|
|||
"job_id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"prompt_text": {"type": "string"},
|
||||
"recipe": _RECIPE_PARAM,
|
||||
"schedule_kind": {"type": "string"},
|
||||
"schedule_expr": {"type": "string"},
|
||||
"timezone": {"type": "string"},
|
||||
|
|
@ -340,6 +530,7 @@ def schedule_run_now_tool() -> ToolSpec:
|
|||
|
||||
__all__ = [
|
||||
"schedule_create_tool",
|
||||
"schedule_propose_tool",
|
||||
"schedule_list_tool",
|
||||
"schedule_update_tool",
|
||||
"schedule_pause_tool",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from runtime.tools.experts.productivity.schedule_tools import (
|
|||
schedule_delete_tool,
|
||||
schedule_list_tool,
|
||||
schedule_pause_tool,
|
||||
schedule_propose_tool,
|
||||
schedule_resume_tool,
|
||||
schedule_run_now_tool,
|
||||
schedule_update_tool,
|
||||
|
|
@ -23,6 +24,7 @@ __all__ = [
|
|||
"schedule_delete_tool",
|
||||
"schedule_list_tool",
|
||||
"schedule_pause_tool",
|
||||
"schedule_propose_tool",
|
||||
"schedule_resume_tool",
|
||||
"schedule_run_now_tool",
|
||||
"schedule_update_tool",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue