From d2693d1c63f83b0b4de460635bd05084d14d676b Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 10 Aug 2026 23:08:05 +0800 Subject: [PATCH] Cut WA alarm re-query loops, auto-deliver xlsx, and add ops schedule templates. TTL-cache UME alarm/diagnostics MCP calls, mark write_xlsx deliverable on WhatsApp unless explicitly false, and expose built-in recipe_template_id playbooks for daily tally / critical xlsx / license checks. Co-authored-by: Cursor --- interfaces/admin/routes.py | 47 +++++++- runtime/chat/tool_runtime.py | 51 +++++++++ runtime/scheduler/recipe.py | 105 ++++++++++++++++++ .../experts/productivity/schedule_tools.py | 26 ++++- runtime/tools/mcp/adapter.py | 8 +- tests/test_admin_scheduled_jobs_api.py | 29 +++++ tests/test_mcp_timeout_and_cache.py | 58 ++++++++++ tests/test_schedule_recipe.py | 33 ++++++ tests/test_wa_xlsx_auto_deliverable.py | 79 +++++++++++++ 9 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 tests/test_wa_xlsx_auto_deliverable.py diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py index 3230c7d1..58e9e9f8 100644 --- a/interfaces/admin/routes.py +++ b/interfaces/admin/routes.py @@ -2305,6 +2305,17 @@ def build_admin_router() -> APIRouter: }, } + @router.get("/admin/api/scheduled-jobs/meta/recipe-templates") + def api_scheduled_jobs_meta_recipe_templates( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + from runtime.scheduler.recipe import list_ops_recipe_templates + + store = get_assistant_store() + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + return {"ok": True, "items": list_ops_recipe_templates()} + @router.get("/admin/api/scheduled-jobs/meta/targets") def api_scheduled_jobs_meta_targets( tenant_id: str = Query(default=""), @@ -2370,13 +2381,43 @@ def build_admin_router() -> APIRouter: prompt_text = str(payload.get("prompt_text") or "").strip() schedule_kind = normalize_schedule_kind(payload.get("schedule_kind") or "cron") schedule_expr = str(payload.get("schedule_expr") or "").strip() - if not name or not prompt_text or not schedule_expr: - return {"ok": False, "error": "name, prompt_text, schedule_expr are required"} + if not name or not schedule_expr: + return {"ok": False, "error": "name, schedule_expr are required"} delivery = payload.get("delivery") if isinstance(payload.get("delivery"), dict) else None if delivery is None: wa_chat = str((payload.get("whatsapp") or {}).get("chat_id") if isinstance(payload.get("whatsapp"), dict) else payload.get("whatsapp_chat_id") or "") delivery = build_default_delivery(store=store, tenant_id=tenant_id, whatsapp_chat_id=wa_chat) + from runtime.scheduler.recipe import ( + list_ops_recipe_templates, + normalize_recipe, + prompt_summary_from_recipe, + recipe_has_playbook, + resolve_ops_recipe_template, + ) + recipe = payload.get("recipe") if isinstance(payload.get("recipe"), dict) else None + if isinstance(recipe, dict): + recipe = normalize_recipe(recipe) + template_id = str(payload.get("recipe_template_id") or payload.get("template_id") or "").strip() + if template_id and not recipe_has_playbook(recipe): + tmpl = resolve_ops_recipe_template(template_id) + if tmpl is None: + return { + "ok": False, + "error": "unknown_recipe_template", + "template_id": template_id, + "available": list_ops_recipe_templates(), + } + recipe = tmpl + if recipe_has_playbook(recipe) and not prompt_text: + prompt_text = prompt_summary_from_recipe(recipe, fallback=name) + if not prompt_text: + return {"ok": False, "error": "name, prompt_text, schedule_expr are required"} + # WhatsApp field ops default to English when delivery targets WA and lang omitted. + lang = str(payload.get("lang") or "").strip() + if not lang: + ch = str((delivery or {}).get("channel") or "").strip().lower() + lang = "en" if ch in {"whatsapp", "wa"} else "zh" row = store.scheduled_job_create( tenant_id=tenant_id, name=name, @@ -2387,7 +2428,7 @@ def build_admin_router() -> APIRouter: description=str(payload.get("description") or ""), interaction_mode=normalize_interaction_mode(payload.get("interaction_mode") or "expert"), specialist=normalize_requested_specialist(payload.get("specialist") or "generalist"), - lang=str(payload.get("lang") or "zh"), + lang=lang, delivery=delivery, recipe=recipe, source_session_id=str(payload.get("source_session_id") or "").strip() or None, diff --git a/runtime/chat/tool_runtime.py b/runtime/chat/tool_runtime.py index ed22e39e..83e42677 100644 --- a/runtime/chat/tool_runtime.py +++ b/runtime/chat/tool_runtime.py @@ -542,6 +542,51 @@ def _resolve_creator_specialist(ctx: ToolExecutionContext) -> str: return "generalist" +def _truthy_flag(raw: Any) -> bool | None: + """Return True/False when the flag is explicit; None when unset.""" + if raw is None: + return None + if isinstance(raw, bool): + return raw + text = str(raw).strip().lower() + if not text: + return None + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return None + + +def _maybe_auto_mark_xlsx_deliverable( + *, + tool_name: str, + tool_args: dict[str, Any], + result: dict[str, Any], + inbound_metadata: dict[str, Any], +) -> dict[str, Any]: + """On WhatsApp/WeChat, auto-mark successful write_xlsx as deliverable unless explicitly false.""" + if not isinstance(result, dict) or result.get("ok") is False: + return result + if str(tool_name or "").strip() != "write_xlsx": + return result + channel = str(inbound_metadata.get("channel") or "").strip().lower() + if channel not in {"whatsapp", "wechat", "wecom"}: + return result + explicit = _truthy_flag(tool_args.get("deliverable")) if isinstance(tool_args, dict) else None + if explicit is False: + return result + if result.get("deliverable") is True: + return result + out = dict(result) + out["deliverable"] = True + out["auto_deliverable"] = True + out["hint"] = ( + "Excel auto-marked deliverable for channel outbound — file will attach on WhatsApp/WeChat." + ) + return out + + @dataclass(frozen=True) class ToolExecutionConfig: max_workers: int = 8 @@ -788,6 +833,12 @@ class ToolExecutor: else: result = _call() out = normalize_tool_result(result) + out = _maybe_auto_mark_xlsx_deliverable( + tool_name=str(tc.name or ""), + tool_args=tool_args if isinstance(tool_args, dict) else {}, + result=out, + inbound_metadata=ctx.inbound_metadata if isinstance(ctx.inbound_metadata, dict) else {}, + ) dur_ms = int((time.perf_counter() - t0) * 1000) try: _tool_exec_log.info( diff --git a/runtime/scheduler/recipe.py b/runtime/scheduler/recipe.py index 2fa8f245..6fd0033c 100644 --- a/runtime/scheduler/recipe.py +++ b/runtime/scheduler/recipe.py @@ -324,9 +324,113 @@ def load_recipe_from_job(job: Any) -> dict[str, Any]: return normalize_recipe(data if isinstance(data, dict) else {}) +# Built-in ops playbooks for WhatsApp field schedules (English-first). +_OPS_RECIPE_TEMPLATE_ALIASES: dict[str, str] = { + "alarm_tally": "ume_alarm_tally_daily", + "alarm_tally_daily": "ume_alarm_tally_daily", + "critical_xlsx": "ume_critical_xlsx_daily", + "critical_xlsx_daily": "ume_critical_xlsx_daily", + "license_check": "ne_license_check_weekly", + "license_weekly": "ne_license_check_weekly", +} + +OPS_RECIPE_TEMPLATES: dict[str, dict[str, Any]] = { + "ume_alarm_tally_daily": { + "version": 1, + "goal": "Post daily UME open-alarm tally to the ops WhatsApp group", + "steps": [ + "Call runUmeDiagnostics or aggregateUmeAlarms for current open alarms", + "Summarize by_severity, top NEs, and freshness in concise English", + "Post a short WhatsApp update (skip xlsx unless counts are very large)", + ], + "constraints": [ + "Prefer English for WhatsApp field ops", + "Do not re-list CLI targets/inventory unless required", + "Do not blind-retry identical failing tool calls", + ], + "success_criteria": [ + "Group receives a severity tally that includes freshness", + ], + "output": {"need_attachments": False}, + }, + "ume_critical_xlsx_daily": { + "version": 1, + "goal": "Send daily critical UME alarm Excel to the ops WhatsApp group", + "steps": [ + "Call ume_alarm_xlsx_report(mode=aggregate_by_host, severity=critical, deliverable=true)", + "If that tool is unavailable: aggregateUmeAlarms then write_xlsx(deliverable=true)", + "Confirm the file is marked deliverable and summarize top hosts in English", + ], + "constraints": [ + "Prefer ume_alarm_xlsx_report over multi-step query+xlsx", + "Never claim a file was sent without deliverable marking", + "Prefer English for WhatsApp field ops", + ], + "success_criteria": [ + "WhatsApp group receives an xlsx attachment of critical alarms by host", + ], + "output": {"need_attachments": True}, + }, + "ne_license_check_weekly": { + "version": 1, + "goal": "Weekly NE license/capacity check summary for ops WhatsApp", + "steps": [ + "Resolve target NEs via listManagedNe or known constants (avoid repeated listCliTargets)", + "Run execManagedNe license/capacity show commands with read_timeout_sec>=60", + "Summarize near-limit or failed NEs in English; attach xlsx only if many rows", + ], + "constraints": [ + "Prefer English for WhatsApp field ops", + "On timeout/unreachable, classify failure and do not blind-retry identical args", + "Keep the group update short and actionable", + ], + "success_criteria": [ + "Group receives a license/capacity status summary for the target set", + ], + "output": {"need_attachments": False}, + }, +} + + +def _normalize_template_id(template_id: str) -> str: + tid = str(template_id or "").strip().lower().replace("-", "_") + return _OPS_RECIPE_TEMPLATE_ALIASES.get(tid, tid) + + +def resolve_ops_recipe_template(template_id: str) -> dict[str, Any] | None: + """Return a normalized recipe for a built-in ops template id, or None.""" + tid = _normalize_template_id(template_id) + raw = OPS_RECIPE_TEMPLATES.get(tid) + if not raw: + return None + recipe = normalize_recipe(raw) + src = dict(recipe.get("source") or {}) + src["template_id"] = tid + recipe["source"] = src + return recipe + + +def list_ops_recipe_templates() -> list[dict[str, Any]]: + """List built-in ops recipe templates (id + goal + attachment hint).""" + items: list[dict[str, Any]] = [] + for tid, raw in OPS_RECIPE_TEMPLATES.items(): + recipe = normalize_recipe(raw) + items.append( + { + "id": tid, + "goal": str(recipe.get("goal") or ""), + "need_attachments": bool((recipe.get("output") or {}).get("need_attachments")), + "aliases": sorted(k for k, v in _OPS_RECIPE_TEMPLATE_ALIASES.items() if v == tid), + } + ) + return items + + __all__ = [ "COMPLEX_PROMPT_HINTS", + "OPS_RECIPE_TEMPLATES", "compile_playbook_instruction", + "list_ops_recipe_templates", "load_recipe_from_job", "looks_like_complex_schedule_prompt", "normalize_recipe", @@ -336,4 +440,5 @@ __all__ = [ "recipe_has_playbook", "recipe_is_empty", "recipe_missing_fields", + "resolve_ops_recipe_template", ] diff --git a/runtime/tools/experts/productivity/schedule_tools.py b/runtime/tools/experts/productivity/schedule_tools.py index 64271e27..cc4fb033 100644 --- a/runtime/tools/experts/productivity/schedule_tools.py +++ b/runtime/tools/experts/productivity/schedule_tools.py @@ -14,6 +14,7 @@ from runtime.scheduler.job_delete import ( resolve_scheduled_jobs_by_id, ) from runtime.scheduler.recipe import ( + list_ops_recipe_templates, looks_like_complex_schedule_prompt, normalize_recipe, parse_recipe_arg, @@ -21,6 +22,7 @@ from runtime.scheduler.recipe import ( prompt_summary_from_recipe, recipe_has_playbook, recipe_missing_fields, + resolve_ops_recipe_template, ) from runtime.scheduler.service import run_scheduled_job_now from runtime.scheduler.system_timezone import default_system_timezone @@ -234,6 +236,18 @@ def schedule_create_tool() -> ToolSpec: 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 {} + template_id = str(args.get("recipe_template_id") or args.get("template_id") or "").strip() + if template_id and not recipe_has_playbook(recipe): + tmpl = resolve_ops_recipe_template(template_id) + if tmpl is None: + return { + "ok": False, + "error": "unknown_recipe_template", + "template_id": template_id, + "available": list_ops_recipe_templates(), + "hint": "Use a known recipe_template_id (e.g. ume_alarm_tally_daily) or pass a full recipe.", + } + recipe = tmpl if recipe_has_playbook(recipe): prompt_text = prompt_summary_from_recipe(recipe, fallback=prompt_text or name) if not prompt_text: @@ -345,7 +359,9 @@ def schedule_create_tool() -> ToolSpec: "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. " + "or pass recipe_template_id for a built-in ops playbook " + "(ume_alarm_tally_daily / ume_critical_xlsx_daily / ne_license_check_weekly). " + "Call schedule_propose and get user confirmation first when drafting custom recipes. " "Delivery follows the current chat channel unless delivery is set explicitly." ), parameters={ @@ -360,6 +376,14 @@ def schedule_create_tool() -> ToolSpec: "description": "Short summary / reminder intent. For playbooks, prefer recipe.goal.", }, "recipe": _RECIPE_PARAM, + "recipe_template_id": { + "type": "string", + "description": ( + "Built-in ops playbook id: ume_alarm_tally_daily, ume_critical_xlsx_daily, " + "ne_license_check_weekly (aliases: alarm_tally, critical_xlsx, license_check). " + "Used when recipe is omitted/incomplete." + ), + }, "schedule_kind": { "type": "string", "enum": ["cron", "once", "interval"], diff --git a/runtime/tools/mcp/adapter.py b/runtime/tools/mcp/adapter.py index 6edf9e9c..a0eab9d1 100644 --- a/runtime/tools/mcp/adapter.py +++ b/runtime/tools/mcp/adapter.py @@ -30,11 +30,17 @@ _MCP_TOOL_TIMEOUT_OVERRIDES_S: dict[str, float] = { "aggregateUmeAlarms": 60.0, } -# Read-mostly inventory/list tools that agents re-call in tight self-loops on WhatsApp. +# Read-mostly inventory/list/alarm tools that agents re-call in tight self-loops on WhatsApp. _MCP_LIST_CACHE_TTL_S: dict[str, float] = { "listCliTargets": 120.0, "listManagedNe": 120.0, "queryUmeNeInventory": 90.0, + # Short TTL: cut identical alarm/diagnostics re-query loops in the same turn. + "queryUmeAlarms": 45.0, + "queryUmeAlarmsRaw": 45.0, + "aggregateUmeAlarms": 45.0, + "aggregateUmeAlarmsRaw": 45.0, + "runUmeDiagnostics": 60.0, } _MCP_LIST_CACHE_LOCK = threading.Lock() diff --git a/tests/test_admin_scheduled_jobs_api.py b/tests/test_admin_scheduled_jobs_api.py index 6754028f..d5cf7190 100644 --- a/tests/test_admin_scheduled_jobs_api.py +++ b/tests/test_admin_scheduled_jobs_api.py @@ -109,6 +109,35 @@ class AdminScheduledJobsApiTests(unittest.TestCase): self.assertEqual(str(job.get("schedule_expr") or ""), "7200") self.assertEqual(str(job.get("prompt_text") or ""), "Stand up hourly") + def test_recipe_templates_meta_and_create(self) -> None: + token = self._login() + headers = {"authorization": f"Bearer {token}"} + meta = self.client.get("/admin/api/scheduled-jobs/meta/recipe-templates", headers=headers) + self.assertEqual(meta.status_code, 200, meta.text) + items = meta.json().get("items") or [] + self.assertTrue(any(str(x.get("id") or "") == "ume_alarm_tally_daily" for x in items)) + + create = self.client.post( + "/admin/api/scheduled-jobs", + headers=headers, + json={ + "name": "Daily alarm tally", + "schedule_kind": "cron", + "schedule_expr": "0 8 * * *", + "recipe_template_id": "alarm_tally", + "delivery": {"channel": "whatsapp", "chat_id": "ops@g.us"}, + "specialist": "ops", + }, + ) + self.assertEqual(create.status_code, 200, create.text) + body = create.json() + self.assertTrue(body.get("ok"), body) + job = body.get("job") or {} + self.assertEqual(str(job.get("lang") or ""), "en") + recipe = job.get("recipe") if isinstance(job.get("recipe"), dict) else {} + self.assertIn("UME", str(recipe.get("goal") or job.get("prompt_text") or "")) + self.assertEqual(str((recipe.get("source") or {}).get("template_id") or ""), "ume_alarm_tally_daily") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_mcp_timeout_and_cache.py b/tests/test_mcp_timeout_and_cache.py index a3a431cf..89778540 100644 --- a/tests/test_mcp_timeout_and_cache.py +++ b/tests/test_mcp_timeout_and_cache.py @@ -108,6 +108,64 @@ class McpTimeoutAndCacheTests(unittest.TestCase): self.assertEqual(second.get("data", {}).get("items", [])[0]["ne_id"], "1") clear_list_cli_targets_cache() + def test_alarm_query_ttl_cache(self) -> None: + clear_list_cli_targets_cache() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td: + store = SqliteStore(str(Path(td) / "ops.sqlite")) + store.upsert_mcp_server( + server_id="netx", + source_type="github", + source_ref="local", + entry_command="python", + entry_args=["-m", "netx_mcp"], + enabled=True, + ) + store.replace_mcp_server_tools( + server_id="netx", + tools=[ + { + "tool_name": "queryUmeAlarms", + "description": "alarms", + "parameters": {"type": "object", "properties": {}}, + }, + { + "tool_name": "aggregateUmeAlarms", + "description": "agg", + "parameters": {"type": "object", "properties": {}}, + }, + { + "tool_name": "runUmeDiagnostics", + "description": "diag", + "parameters": {"type": "object", "properties": {}}, + }, + ], + ) + specs = {s.name: s for s in materialize_mcp_tools(store)} + calls = {"n": 0} + + def fake_call_tool(self, tool_name, arguments=None): # type: ignore[no-untyped-def] + calls["n"] += 1 + return {"ok": True, "data": {"tool": tool_name, "n": calls["n"]}} + + with patch("runtime.tools.mcp.adapter.McpProcessRuntime.call_tool", fake_call_tool): + q = specs["mcp__netx__queryUmeAlarms"] + a = specs["mcp__netx__aggregateUmeAlarms"] + d = specs["mcp__netx__runUmeDiagnostics"] + q1 = q.handler({"severity": "critical"}) + q2 = q.handler({"severity": "critical"}) + a1 = a.handler({"top_ne": 10}) + a2 = a.handler({"top_ne": 10}) + d1 = d.handler({}) + d2 = d.handler({}) + self.assertEqual(calls["n"], 3) + self.assertFalse(q1.get("cache_hit")) + self.assertTrue(q2.get("cache_hit")) + self.assertTrue(a2.get("cache_hit")) + self.assertTrue(d2.get("cache_hit")) + self.assertEqual(a1.get("data", {}).get("tool"), "aggregateUmeAlarms") + self.assertEqual(d1.get("data", {}).get("tool"), "runUmeDiagnostics") + clear_list_cli_targets_cache() + class InvalidArgFormatTests(unittest.TestCase): def test_format_includes_example(self) -> None: diff --git a/tests/test_schedule_recipe.py b/tests/test_schedule_recipe.py index da63e783..cac9c802 100644 --- a/tests/test_schedule_recipe.py +++ b/tests/test_schedule_recipe.py @@ -7,12 +7,14 @@ from pathlib import Path from runtime.scheduler.recipe import ( compile_playbook_instruction, + list_ops_recipe_templates, looks_like_complex_schedule_prompt, normalize_recipe, preview_markdown, prompt_summary_from_recipe, recipe_has_playbook, recipe_missing_fields, + resolve_ops_recipe_template, ) from runtime.scheduler.turn_text import build_scheduled_turn_instruction, scheduled_turn_system_suffix from runtime.tools.experts.productivity.schedule_tools import ( @@ -67,6 +69,18 @@ class RecipeHelpersTests(unittest.TestCase): self.assertTrue(looks_like_complex_schedule_prompt("继续刚才那个生成 PDF 流程")) self.assertFalse(looks_like_complex_schedule_prompt("提醒喝水")) + def test_ops_recipe_templates(self) -> None: + items = list_ops_recipe_templates() + ids = {str(x.get("id") or "") for x in items} + self.assertIn("ume_alarm_tally_daily", ids) + self.assertIn("ume_critical_xlsx_daily", ids) + self.assertIn("ne_license_check_weekly", ids) + tmpl = resolve_ops_recipe_template("alarm_tally") + assert tmpl is not None + self.assertTrue(recipe_has_playbook(tmpl)) + self.assertEqual((tmpl.get("source") or {}).get("template_id"), "ume_alarm_tally_daily") + self.assertIsNone(resolve_ops_recipe_template("nope")) + def test_turn_instruction_modes(self) -> None: reminder = build_scheduled_turn_instruction(prompt_text="喝水", mode="scheduled", lang="zh") self.assertIn("提醒意图", reminder) @@ -183,6 +197,25 @@ class ScheduleRecipeToolTests(unittest.TestCase): self.assertFalse(out.get("ok")) self.assertEqual(out.get("error"), "recipe_required") + def test_create_from_ops_recipe_template(self) -> None: + out = schedule_create_tool().handler( + { + "tenant_id": self.tenant_id, + "owner_user_id": self.user_id, + "name": "Daily tally", + "recipe_template_id": "ume_alarm_tally_daily", + "schedule_kind": "cron", + "schedule_expr": "0 8 * * *", + "lang": "en", + } + ) + self.assertTrue(out.get("ok"), out) + job = out.get("job") or {} + recipe = job.get("recipe") or {} + self.assertTrue(recipe_has_playbook(recipe)) + self.assertEqual((recipe.get("source") or {}).get("template_id"), "ume_alarm_tally_daily") + self.assertIn("UME", str(job.get("prompt_text") or "")) + def test_simple_reminder_still_works(self) -> None: out = schedule_create_tool().handler( { diff --git a/tests/test_wa_xlsx_auto_deliverable.py b/tests/test_wa_xlsx_auto_deliverable.py new file mode 100644 index 00000000..81eee663 --- /dev/null +++ b/tests/test_wa_xlsx_auto_deliverable.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from pathlib import Path + +from runtime.chat.tool_runtime import ToolExecutionContext, ToolExecutor, _maybe_auto_mark_xlsx_deliverable +from runtime.tools.base import ToolRegistry, ToolSpec +from svc.llm.chat_models import LLMToolCall +from svc.persistence.sqlite_store import SqliteStore + + +def test_helper_marks_on_whatsapp() -> None: + out = _maybe_auto_mark_xlsx_deliverable( + tool_name="write_xlsx", + tool_args={}, + result={"ok": True, "attachment_id": "a1", "name": "r.xlsx"}, + inbound_metadata={"channel": "whatsapp"}, + ) + assert out.get("deliverable") is True + assert out.get("auto_deliverable") is True + + +def test_helper_respects_explicit_false() -> None: + out = _maybe_auto_mark_xlsx_deliverable( + tool_name="write_xlsx", + tool_args={"deliverable": False}, + result={"ok": True, "attachment_id": "a1"}, + inbound_metadata={"channel": "whatsapp"}, + ) + assert out.get("deliverable") is not True + assert out.get("auto_deliverable") is not True + + +def test_helper_skips_non_channel() -> None: + out = _maybe_auto_mark_xlsx_deliverable( + tool_name="write_xlsx", + tool_args={}, + result={"ok": True, "attachment_id": "a1"}, + inbound_metadata={"channel": "web"}, + ) + assert out.get("deliverable") is not True + + +def test_executor_auto_marks_write_xlsx_on_whatsapp(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "ops.sqlite")) + sess = store.create_session("t1") + + def handler(_args: dict) -> dict: + return { + "ok": True, + "attachment_id": "att-xlsx", + "name": "report.xlsx", + "mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + } + + reg = ToolRegistry( + [ + ToolSpec( + name="write_xlsx", + description="xlsx", + parameters={"type": "object", "properties": {}}, + handler=handler, + ) + ] + ) + ctx = ToolExecutionContext( + store=store, + tools=reg, + session_id=sess.id, + lang="en", + inbound_metadata={"channel": "whatsapp"}, + turn_uuid="turn-xlsx-1", + ) + out, _dur = ToolExecutor()._execute_tool( + ctx, + LLMToolCall(id="c1", name="write_xlsx", arguments={}), + ) + assert out.get("ok") is True + assert out.get("deliverable") is True + assert out.get("auto_deliverable") is True