Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/lingtai/tools/bash/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand All @@ -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 (`<agent>/`):
Expand Down
2 changes: 1 addition & 1 deletion src/lingtai/tools/bash/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/lingtai/tools/bash/manual/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/lingtai/tools/daemon/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 (`<parent>/`):
Expand Down
4 changes: 2 additions & 2 deletions src/lingtai/tools/daemon/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/lingtai/tools/daemon/manual/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion tests/test_bash_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]})

Expand Down
5 changes: 4 additions & 1 deletion tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
5 changes: 4 additions & 1 deletion tests/test_daemon_backend_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
23 changes: 23 additions & 0 deletions tests/test_task_card_handoff_guidance.py
Original file line number Diff line number Diff line change
@@ -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}"