From d3e12781ff2234fd9a53430fd98a9d90c07670dc Mon Sep 17 00:00:00 2001 From: huangzesen Date: Wed, 15 Jul 2026 23:44:27 -0700 Subject: [PATCH] feat(tools): add Task Card progress handoff guidance --- src/lingtai/tools/bash/CONTRACT.md | 8 +++++++- src/lingtai/tools/bash/__init__.py | 2 +- src/lingtai/tools/bash/manual/SKILL.md | 9 +++++++++ src/lingtai/tools/daemon/CONTRACT.md | 8 +++++++- src/lingtai/tools/daemon/__init__.py | 4 ++-- src/lingtai/tools/daemon/manual/SKILL.md | 8 ++++++++ tests/test_bash_async.py | 5 ++++- tests/test_daemon.py | 5 ++++- tests/test_daemon_backend_options.py | 5 ++++- tests/test_task_card_handoff_guidance.py | 23 +++++++++++++++++++++++ 10 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 tests/test_task_card_handoff_guidance.py diff --git a/src/lingtai/tools/bash/CONTRACT.md b/src/lingtai/tools/bash/CONTRACT.md index 5ffc3877..0930d558 100644 --- a/src/lingtai/tools/bash/CONTRACT.md +++ b/src/lingtai/tools/bash/CONTRACT.md @@ -82,7 +82,7 @@ for `command` and `job_id`. `action` defaults to `run`. | Action | Required inputs | Optional inputs | Success output | Error shapes | |---|---|---|---|---| | `run` (sync) | Provider schema: `command`, `reminder`; runtime consumes `command` only | `working_dir`, `timeout` (default 30), `summary` | `{status: "ok", exit_code, stdout, stderr, ok, command_status, warning?}` | `{status: "error", message}` — empty command, policy-denied, cwd outside sandbox, timeout (with broad-scan hint), or spawn failure | -| `run` (async) | Provider/runtime: `command`, `async: true`, `reminder` | `working_dir`, `summary` | `{status: "ok", job_id, pid, message, handoff}`; `handoff` tells the model it may go idle or call `system(action='sleep')` while waiting for the terminal notification; read `shell-manual` and `notification-manual` for details | `{status: "error", message}` — same validation errors, invalid boolean/non-numeric/non-finite/negative/too-large `reminder`, plus `Failed to start async job: ...` | +| `run` (async) | Provider/runtime: `command`, `async: true`, `reminder` | `working_dir`, `summary` | `{status: "ok", job_id, pid, message, handoff}`; `handoff` tells the model it may go idle or call `system(action='sleep')` while waiting for the terminal notification, and conditionally says that if Telegram is connected and a Task Card is available for the current turn, the model should use it to report progress via `telegram(action='manual')` and that manual's `Programmable Task Card` section; read `shell-manual` and `notification-manual` for details | `{status: "error", message}` — same validation errors, invalid boolean/non-numeric/non-finite/negative/too-large `reminder`, plus `Failed to start async job: ...` | | `poll` | Provider schema: `job_id`, `reminder`; runtime consumes `job_id` only | — | running: `{status: "running", job_id, pid?}` while the recorded supervisor may still commit; known finished: `{status: "done", exit_status_known: true, exit_code, stdout, stderr, ok, command_status, warning?}`; unrecoverable/legacy terminal: `{status: "done", exit_status_known: false, exit_code: null, stdout, stderr}` | `{status: "error", message}` — missing/invalid `job_id`, `Job not found`, or an already terminal-consumed job | | `cancel` | Provider schema: `job_id`, `reminder`; runtime consumes `job_id` only | — | `{status: "cancelled", job_id}` only after the supervisor has committed the held child's exact terminal status and cancellation atomically consumes/suppresses the job | `{status: "error", message}` — missing/invalid `job_id`, `Job not found`, terminal job, legacy job, or a durable cancellation request still awaiting a terminal commit (which remains pollable/remindable) | @@ -103,6 +103,12 @@ because the timer backend cannot accept them safely. The schema default is 1800 seconds. Direct runtime calls that omit it still get 1800 seconds, so older callers keep working even though providers see the field as required. +Agents following an async success `handoff` MUST treat Task Card guidance as +conditional: use the Task Card only when Telegram is connected and a Task Card +is available for the current turn, and read `telegram(action='manual')` for the +`Programmable Task Card` details. Shell does not create or require a watcher and +does not import or call Telegram/Task Card runtime code. + ## State & storage All paths are relative to the agent working directory (`/`): diff --git a/src/lingtai/tools/bash/__init__.py b/src/lingtai/tools/bash/__init__.py index 0c5e5fae..03ddec36 100644 --- a/src/lingtai/tools/bash/__init__.py +++ b/src/lingtai/tools/bash/__init__.py @@ -801,7 +801,7 @@ def arm_from_return(current: dict) -> dict: return { "status": "ok", "job_id": job_id, "pid": pid, "message": f'Job started. Use shell(action="poll", job_id="{job_id}") to check.', - "handoff": "While waiting, go idle or call system(action='sleep'); the terminal result will arrive and wake you as a notification; read shell-manual and notification-manual for details.", + "handoff": "While waiting, go idle or call system(action='sleep'); the terminal result will arrive and wake you as a notification; read shell-manual and notification-manual for details. If Telegram is connected and a Task Card is available for the current turn, use it to report progress; call `telegram(action='manual')` and follow its `Programmable Task Card` section for details.", } if state and self._terminal(state.get("status")): break diff --git a/src/lingtai/tools/bash/manual/SKILL.md b/src/lingtai/tools/bash/manual/SKILL.md index eda3563c..167435de 100644 --- a/src/lingtai/tools/bash/manual/SKILL.md +++ b/src/lingtai/tools/bash/manual/SKILL.md @@ -229,6 +229,15 @@ reading the whole file when you only need recent events. shell(action="cancel", job_id="job-a1b2c3d4e5f678901234567890abcdef", reminder=1800) ``` +- **Use a Task Card for progress when one is available for this turn.** + The async success `handoff` is conditional: if Telegram is connected and a + Task Card is available for the current turn, use it to report progress; call + `telegram(action='manual')` and follow its `Programmable Task Card` section + for details. The shell tool does not create a Task Card automatically or + require a watcher; the calling agent follows the Task Card manual. This keeps + background command lifecycle and notification behavior unchanged while giving + Telegram-originated turns a better progress surface. + - **If repeated-call `_advisory` appears on `shell(action="poll")`, stop tight polling.** The poll already executed; the advisory is not a block. If the job is still running and nothing meaningful changed, handle any human diff --git a/src/lingtai/tools/daemon/CONTRACT.md b/src/lingtai/tools/daemon/CONTRACT.md index 79af2dda..f51e5dea 100644 --- a/src/lingtai/tools/daemon/CONTRACT.md +++ b/src/lingtai/tools/daemon/CONTRACT.md @@ -98,7 +98,7 @@ divergence this threshold triggers. | Action | Required inputs | Optional inputs | Success output | Error shapes | |---|---|---|---|---| -| `emanate` | `tasks[]` (each `task`+`tools`) | `backend`, `max_turns`, `timeout`, per-task `skills`/`mcp`/`preset`/`backend_options`/`system_prompt`/`context_token_limit` | `{status: "dispatched", count, ids: [...], group_id, handoff}`; `handoff` tells the model it may go idle or call `system(action='sleep')` while waiting for the terminal notification; read `daemon-manual` and `notification-manual` for details | `{status: "error", message}` — `No tasks provided`, bad `max_turns`/`timeout`/`context_token_limit`, tool-surface/preset build failure | +| `emanate` | `tasks[]` (each `task`+`tools`) | `backend`, `max_turns`, `timeout`, per-task `skills`/`mcp`/`preset`/`backend_options`/`system_prompt`/`context_token_limit` | `{status: "dispatched", count, ids: [...], group_id, handoff}`; `handoff` tells the model it may go idle or call `system(action='sleep')` while waiting for the terminal notification, and conditionally says that if Telegram is connected and a Task Card is available for the current turn, the model should use it to report progress via `telegram(action='manual')` and that manual's `Programmable Task Card` section; read `daemon-manual` and `notification-manual` for details | `{status: "error", message}` — `No tasks provided`, bad `max_turns`/`timeout`/`context_token_limit`, tool-surface/preset build failure | | `list` | — | `contains`, `status`, `include_done` (default true), `last` | `{...}` list blob of matching emanations (running + persisted history) | `{status: "error", message}` | | `ask` | `id`, `message` | — | `{status: "sent", id, output}` (CLI ask returns immediately; `{status: "sent", id, async: true, ...}`) | `{status: "error", id, message}` — unknown/absent id, backend `ask` unsupported, or busy | | `check` | `id` | `last` (default 20), `truncate` (default 500) | `{id, run_id, state, backend, path, turn, current_tool, elapsed_s, finished_at, tokens, result_preview, result_path, last_output, error, events: [...]}` | `{status: "error", message}` — unknown id, no run_dir, invalid `last`/`truncate`, or read failure | @@ -109,6 +109,12 @@ divergence this threshold triggers. system notification per emanation. `check` classifies terminal state from the recorded run-dir snapshot first (see `_classify_terminal_state`). +Agents following an `emanate` success `handoff` MUST treat Task Card guidance as +conditional: use the Task Card only when Telegram is connected and a Task Card +is available for the current turn, and read `telegram(action='manual')` for the +`Programmable Task Card` details. Daemon does not create or require a watcher +and does not import or call Telegram/Task Card runtime code. + ## State & storage All paths are relative to the parent agent working directory (`/`): diff --git a/src/lingtai/tools/daemon/__init__.py b/src/lingtai/tools/daemon/__init__.py index 233b2330..7258d5b7 100644 --- a/src/lingtai/tools/daemon/__init__.py +++ b/src/lingtai/tools/daemon/__init__.py @@ -3797,7 +3797,7 @@ def _handle_emanate(self, tasks: list[dict], return {"status": "dispatched", "count": len(tasks), "ids": ids, "group_id": group_id, - "handoff": "While waiting, go idle or call system(action='sleep'); the terminal result will arrive and wake you as a notification; read daemon-manual and notification-manual for details."} + "handoff": "While waiting, go idle or call system(action='sleep'); the terminal result will arrive and wake you as a notification; read daemon-manual and notification-manual for details. If Telegram is connected and a Task Card is available for the current turn, use it to report progress; call `telegram(action='manual')` and follow its `Programmable Task Card` section for details."} def _handle_emanate_cli( self, @@ -4064,7 +4064,7 @@ def _handle_emanate_cli( tasks=[{"task": s["task"][:80], "tools": s.get("tools", [])} for s in tasks]) return {"status": "dispatched", "count": len(tasks), "ids": ids, "group_id": group_id, "backend": backend, - "handoff": "While waiting, go idle or call system(action='sleep'); the terminal result will arrive and wake you as a notification; read daemon-manual and notification-manual for details."} + "handoff": "While waiting, go idle or call system(action='sleep'); the terminal result will arrive and wake you as a notification; read daemon-manual and notification-manual for details. If Telegram is connected and a Task Card is available for the current turn, use it to report progress; call `telegram(action='manual')` and follow its `Programmable Task Card` section for details."} # Start watchdog — scoped to this batch's CLI procs (group_id) so an # earlier batch's timeout can never kill this one's subprocesses. diff --git a/src/lingtai/tools/daemon/manual/SKILL.md b/src/lingtai/tools/daemon/manual/SKILL.md index 28af5420..d4742cfc 100644 --- a/src/lingtai/tools/daemon/manual/SKILL.md +++ b/src/lingtai/tools/daemon/manual/SKILL.md @@ -202,6 +202,14 @@ files, not standalone top-level skills. notification arrives on the system channel carrying the daemon id, terminal status, task summary, and the result/error path. React to it with `daemon(action="check", id=...)` (and read `result.txt` for the full output). +- **Use a Task Card for progress when one is available for this turn.** + The dispatch success `handoff` is conditional: if Telegram is connected and a + Task Card is available for the current turn, use it to report progress; call + `telegram(action='manual')` and follow its `Programmable Task Card` section + for details. The daemon tool does not create a Task Card automatically or + require a watcher; the calling agent follows the Task Card manual. This keeps + daemon lifecycle and terminal-notification behavior unchanged while giving + Telegram-originated turns a better progress surface. - **`check` still resolves a daemon after refresh/molt.** A refresh/molt gives you a fresh daemon registry with no in-memory entries, but the run folders and their notifications survive on disk. New daemon ids are compact run ids diff --git a/tests/test_bash_async.py b/tests/test_bash_async.py index 7d4cf143..9ee85d29 100644 --- a/tests/test_bash_async.py +++ b/tests/test_bash_async.py @@ -273,7 +273,10 @@ def test_schema_requires_reminder_with_runtime_default(self, tmp_path): assert result["handoff"] == ( "While waiting, go idle or call system(action='sleep'); the terminal result " "will arrive and wake you as a notification; read shell-manual and " - "notification-manual for details." + "notification-manual for details. If Telegram is connected and a Task Card " + "is available for the current turn, use it to report progress; call " + "`telegram(action='manual')` and follow its `Programmable Task Card` " + "section for details." ) mgr.handle({"action": "cancel", "command": "", "job_id": result["job_id"]}) diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 9db68f2b..2e133e4b 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -1117,7 +1117,10 @@ def test_handle_emanate_dispatches_and_returns_ids(tmp_path, monkeypatch): assert result["handoff"] == ( "While waiting, go idle or call system(action='sleep'); the terminal result " "will arrive and wake you as a notification; read daemon-manual and " - "notification-manual for details." + "notification-manual for details. If Telegram is connected and a Task Card " + "is available for the current turn, use it to report progress; call " + "`telegram(action='manual')` and follow its `Programmable Task Card` " + "section for details." ) assert result["count"] == 2 ids = result["ids"] diff --git a/tests/test_daemon_backend_options.py b/tests/test_daemon_backend_options.py index f5001c11..1e2c4671 100644 --- a/tests/test_daemon_backend_options.py +++ b/tests/test_daemon_backend_options.py @@ -256,7 +256,10 @@ def test_emanate_cli_no_options_omits_fields(tmp_path, monkeypatch): assert result["handoff"] == ( "While waiting, go idle or call system(action='sleep'); the terminal result " "will arrive and wake you as a notification; read daemon-manual and " - "notification-manual for details." + "notification-manual for details. If Telegram is connected and a Task Card " + "is available for the current turn, use it to report progress; call " + "`telegram(action='manual')` and follow its `Programmable Task Card` " + "section for details." ) state = wait_daemon_terminal(mgr._emanations[result["ids"][0]]["run_dir"]) diff --git a/tests/test_task_card_handoff_guidance.py b/tests/test_task_card_handoff_guidance.py new file mode 100644 index 00000000..69d8d892 --- /dev/null +++ b/tests/test_task_card_handoff_guidance.py @@ -0,0 +1,23 @@ +"""Focused checks for Task Card progress guidance in async handoffs.""" +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +GUIDANCE_FRAGMENTS = [ + "Task Card", + "telegram(action='manual')", + "Programmable Task Card", +] +DOCS = [ + ROOT / "src/lingtai/tools/bash/CONTRACT.md", + ROOT / "src/lingtai/tools/daemon/CONTRACT.md", + ROOT / "src/lingtai/tools/bash/manual/SKILL.md", + ROOT / "src/lingtai/tools/daemon/manual/SKILL.md", +] + + +def test_contracts_and_manuals_carry_task_card_handoff_guidance(): + for path in DOCS: + text = path.read_text(encoding="utf-8") + for fragment in GUIDANCE_FRAGMENTS: + assert fragment in text, f"{fragment!r} missing from {path}"