diff --git a/src/lingtai/kernel/base_agent/ANATOMY.md b/src/lingtai/kernel/base_agent/ANATOMY.md index 87c79173..9f31b09d 100644 --- a/src/lingtai/kernel/base_agent/ANATOMY.md +++ b/src/lingtai/kernel/base_agent/ANATOMY.md @@ -81,4 +81,4 @@ Generic agent kernel. Single class `BaseAgent` with methods distributed across 6 - **`__init__.py` is large.** This is intentional — the constructor, properties, state machine, hooks, cross-cutting infrastructure (`_save_chat_history`, `_log`, notification sync, legacy tc_inbox drain), and pass-through stubs all live here. The package is not "thin shell + 6 leaves"; the remaining code is genuinely cross-cutting or bound to the class definition. - **Pass-through pattern.** Each extracted method becomes a 2-line stub in `__init__.py` (lazy import + call). This preserves the `BaseAgent` class interface while the implementation lives in submodules. The `self` → `agent` conversion is mechanical but `_heartbeat_loop` (~264 lines, 15+ cross-cluster calls) deserves extra-careful review. - **Subclass overrides stay on `__init__.py`.** `_activate_preset`, `_activate_default_preset`, `_build_launch_cmd`, `_pre_request`, `_post_request`, `_on_tool_result_hook`, `_cpr_agent` are all overridden by the `Agent` subclass in the wrapper package. They must remain as methods on `BaseAgent`. -- **Telegram Task Card hooks (turn-local, route B).** Hooks in `__init__.py` implement the batched Task Card lifecycle — a rolling window of the newest **N ordinary tool rows** (env-configurable, default 1), not a single transient current step. `_setup_telegram_task_card` (`base_agent/__init__.py:2870-2937`) captures the current Telegram inbound route (`data.previews[0].message_ref` → `account:chat_id`), resolves the Telegram MCP client from `_mcp_clients_by_tool`, and stores a fingerprint-deduped turn-local `_telegram_task_card_context` (rows / generation / monotonic `clock` / `wall_clock` / heartbeat). `_on_tool_pre_dispatch_hook` (`base_agent/__init__.py:2342-2410`) appends one row per tool call in actual pre-dispatch order, opens a new epoch when no tool row is active (bumps `generation` so a stale heartbeat can't overwrite the window; supersedes a lingering API-error row, since a fresh tool batch means the LLM responded), then caps ordinary tool rows in place via `_cap_task_card_tool_rows` (`base_agent/__init__.py:2412-2428`) to `_task_card_max_tool_rows()` (`base_agent/__init__.py:83`, reads `LINGTAI_TASK_CARD_MAX_TOOL_ROWS`; positive int N, else fail-safe to 1; N is not otherwise clamped, so a very large N can exceed the render budget/transport limit) — the API-error row is retained and never evicted by the tool cap. `_on_tool_result_hook` (`base_agent/__init__.py:2279`) → `_freeze_task_card_row` (`base_agent/__init__.py:2309`) freezes the matching row (final whole-second elapsed + done) while others keep ticking; a row already scrolled out of the window has no matching `call_id`, so its late result is a no-op and cannot mutate the window. Rendering (create lazily, else edit the same card) is `_render_task_card` (`base_agent/__init__.py:2443`); the 0.5s heartbeat `_task_card_heartbeat_tick` (`base_agent/__init__.py:2724`) / `_start_task_card_heartbeat` (`base_agent/__init__.py:2759`) refreshes only active tool-row elapsed (floored to whole seconds) under the lock and no-ops on a stale `generation`. Each render reverse-calls the **private, unlisted MCP tool name** `_TASK_CARD_TOOL` (`= "_lingtai_telegram_task_card"`, `base_agent/__init__.py:71`) via `call_tool(_TASK_CARD_TOOL, {"sub_action": ..., ...})` — it sends **no** `action`; the Telegram server forces `_task_card_update` server-side (see `tools/mcp/ANATOMY.md`). A recursion guard skips the hook for `_TASK_CARD_TOOL` itself. Because `call_tool` reports tool-level failures as an error *dict* (and can also raise), the hooks inspect the result so an error is never treated as a created card (no fake success), compute the `message_id` once, stay fail-open, and make both error-dicts and raised exceptions observable via content-free warnings (phase/tool + status-or-exception-class only — never reasoning, chat id, account, card id, or provider text). LLM/provider API failures are surfaced onto the same card as one stable sentinel row by `_report_task_card_api_error` (`base_agent/__init__.py:2648`). `_teardown_telegram_task_card` (`base_agent/__init__.py:2939`) freezes the card on its concrete last batch (the retained newest-N rows + `✓` markers + final elapsed) with no generic overall `DONE` subject, surfaces a finalize error/exception the same content-free way, and clears context in `finally`. Setup is called in `lifecycle.py:549` after `_sync_notifications()` inside the heartbeat loop. Ordinary-request teardown runs in `turn.py:1123` (finally block) and in `_post_request` at `__init__.py:2277` (normal path). tc-wake teardown runs in `turn.py:1332` (finally block around the wire-drive handler). The real turn→ToolExecutor execute-hook wiring is at `turn.py:1732-1734` (`on_pre_dispatch_hook=getattr(agent, "_on_tool_pre_dispatch_hook", None)`). The serial pre-future parallel hook block is at `tool_executor.py:1424-1432` (Phase 2, before `ThreadPoolExecutor` spawns futures), so parallel pre-dispatch order is deterministic. The MCP consumer side is `TelegramManager._handle_task_card_update` (`src/lingtai/mcp_servers/telegram/manager.py:1374`), a private action not in SCHEMA, dispatched via `manager.handle()` at `src/lingtai/mcp_servers/telegram/manager.py:466` → `action == "_task_card_update"`; its update-first resident-card singleton and single card-level `时间 HH:MM:SS UTC±HH` line are documented in `src/lingtai/mcp_servers/ANATOMY.md`. +- **Telegram Task Card hooks (turn-local, route B).** Hooks in `__init__.py` implement the batched Task Card lifecycle — a rolling window of the newest **N ordinary tool rows** (env-configurable, default 1), not a single transient current step. `_setup_telegram_task_card` (`base_agent/__init__.py:2884-2952`) captures the current Telegram inbound route (`data.previews[0].message_ref` → `account:chat_id`), resolves the Telegram MCP client from `_mcp_clients_by_tool`, and stores a fingerprint-deduped turn-local `_telegram_task_card_context` (rows / generation / monotonic `clock` / `wall_clock` / heartbeat). `_on_tool_pre_dispatch_hook` (`base_agent/__init__.py:2356-2423`) appends one row per tool call in actual pre-dispatch order, opens a new epoch when no tool row is active (bumps `generation` so a stale heartbeat can't overwrite the window; supersedes a lingering API-error row, since a fresh tool batch means the LLM responded), then caps ordinary tool rows in place via `_cap_task_card_tool_rows` (`base_agent/__init__.py:2426-2444`) to `_task_card_max_tool_rows()` (`base_agent/__init__.py:84`, reads `LINGTAI_TASK_CARD_MAX_TOOL_ROWS`; positive int N, else fail-safe to 1; N is not otherwise clamped, so a very large N can exceed the render budget/transport limit) — the API-error row is retained and never evicted by the tool cap. `_on_tool_result_hook` (`base_agent/__init__.py:2293`) → `_freeze_task_card_row` (`base_agent/__init__.py:2323`) freezes the matching row (final whole-second elapsed + done) while others keep ticking; a row already scrolled out of the window has no matching `call_id`, so its late result is a no-op and cannot mutate the window. Rendering (create lazily, else edit the same card) is `_render_task_card` (`base_agent/__init__.py:2457`); the 0.5s heartbeat `_task_card_heartbeat_tick` (`base_agent/__init__.py:2738`) / `_start_task_card_heartbeat` (`base_agent/__init__.py:2773`) refreshes only active tool-row elapsed (floored to whole seconds) under the lock and no-ops on a stale `generation`. Each render reverse-calls the **private, unlisted MCP tool name** `_TASK_CARD_TOOL` (`= "_lingtai_telegram_task_card"`, `base_agent/__init__.py:72`) via `call_tool(_TASK_CARD_TOOL, {"sub_action": ..., ...})` — it sends **no** `action`; the Telegram server forces `_task_card_update` server-side (see `tools/mcp/ANATOMY.md`). A recursion guard skips the hook for `_TASK_CARD_TOOL` itself. Because `call_tool` reports tool-level failures as an error *dict* (and can also raise), the hooks inspect the result so an error is never treated as a created card (no fake success), compute the `message_id` once, stay fail-open, and make both error-dicts and raised exceptions observable via content-free warnings (phase/tool + status-or-exception-class only — never reasoning, chat id, account, card id, or provider text). LLM/provider API failures are surfaced onto the same card as one stable sentinel row by `_report_task_card_api_error` (`base_agent/__init__.py:2662`). `_teardown_telegram_task_card` (`base_agent/__init__.py:2954`) freezes the card on its concrete last batch (the retained newest-N rows + `✓` markers + final elapsed) with no generic overall `DONE` subject, surfaces a finalize error/exception the same content-free way, and clears context in `finally`. Setup is called in `lifecycle.py:549` after `_sync_notifications()` inside the heartbeat loop. Ordinary-request teardown runs in `turn.py:1123` (finally block) and in `_post_request` at `__init__.py:2291` (normal path). tc-wake teardown runs in `turn.py:1332` (finally block around the wire-drive handler). The real turn→ToolExecutor execute-hook wiring is at `turn.py:1732-1734` (`on_pre_dispatch_hook=getattr(agent, "_on_tool_pre_dispatch_hook", None)`). The serial pre-future parallel hook block is at `tool_executor.py:1424-1432` (Phase 2, before `ThreadPoolExecutor` spawns futures), so parallel pre-dispatch order is deterministic. The MCP consumer side is `TelegramManager._handle_task_card_update` (`src/lingtai/mcp_servers/telegram/manager.py:1377`), a private action not in SCHEMA, dispatched via `manager.handle()` at `src/lingtai/mcp_servers/telegram/manager.py:472` → `action == "_task_card_update"`; its update-first resident-card singleton and single card-level `时间 HH:MM:SS UTC±HH` line are documented in `src/lingtai/mcp_servers/ANATOMY.md`. diff --git a/src/lingtai/mcp_servers/ANATOMY.md b/src/lingtai/mcp_servers/ANATOMY.md index 48873a50..5462af6a 100644 --- a/src/lingtai/mcp_servers/ANATOMY.md +++ b/src/lingtai/mcp_servers/ANATOMY.md @@ -38,7 +38,7 @@ Curated and built-in MCP server package implementations shipped inside the `ling | `_skill.py` | Shared bundled-skill helper: re-exports the kernel-owned `split_frontmatter` from `lingtai.kernel._frontmatter` (one impl shared with the prompt-section catalog; kernel never imports the wrapper), `load_skill()` loads package `SKILL.md`, `manual_action_description()` injects frontmatter into the schema, and `manual_payload()` returns the manual body + absolute path without sidecar lists (`_skill.py:36-79`). | | `_identity.py` | Shared public-identity envelope/path/write helper for curated messaging MCPs: builds the `lingtai.mcp.identity.v1` document, computes `system/mcp_identities/.json`, and performs the newline-terminated atomic JSON write. Provider-specific account fields and redaction stay in each provider. | | `daemon_common/` | Built-in daemon lifecycle MCP. `daemon_common/server.py:1-151` exposes `finish(status, summary?, reason?, artifacts?)`, validates the call, and atomically writes the internal per-run `daemon_completion.json` file named by `LINGTAI_DAEMON_COMPLETION_FILE`; daemon runners validate that file before allowing success. | -| `telegram/`, `imap/`, `feishu/`, `wechat/`, `whatsapp/`, `cloud_mail/` | Curated messaging MCPs. TelegramManager requires an injected `NotificationStorePort`; `telegram/server.py` constructs one POSIX adapter, and handled-mirror policy runs against the current payload in one compare-update so newer mirrors survive (`src/lingtai/mcp_servers/telegram/manager.py:380-391`, `src/lingtai/mcp_servers/telegram/manager.py:1223-1265`, `src/lingtai/mcp_servers/telegram/server.py:655-663`). The external LICC path/envelope and persistent-message lanes remain unchanged. | +| `telegram/`, `imap/`, `feishu/`, `wechat/`, `whatsapp/`, `cloud_mail/` | Curated messaging MCPs. TelegramManager requires an injected `NotificationStorePort`; `telegram/server.py` constructs one POSIX adapter, and handled-mirror policy runs against the current payload in one compare-update so newer mirrors survive (`src/lingtai/mcp_servers/telegram/manager.py:397-411`, `src/lingtai/mcp_servers/telegram/manager.py:1242-1283`, `src/lingtai/mcp_servers/telegram/server.py:671-677`). The external LICC path/envelope and persistent-message lanes remain unchanged. | | Per-package `SKILL.md` | The human/agent-facing bundled manual. If a manual has sidecars, the sidecar inventory and relative paths live in this markdown, not in the tool payload. | | `pyproject.toml` package-data entries | Ships every curated MCP `SKILL.md`; `reference/**/*` and `assets/**/*` are also packaged for future sidecar files (`pyproject.toml:81-86`). | @@ -62,5 +62,5 @@ The package itself is mostly code + packaged manuals. Runtime state is per-agent - **Manual sidecar minimal contract:** `action="manual"` returns the main `SKILL.md` body, parsed metadata, and the main `SKILL.md` absolute `path` only. Concrete `assets/` and `reference/` lists MUST NOT be returned as structured tool fields; `SKILL.md` is the single source of truth for what sidecars exist and how to follow their relative paths. - **Packaging discipline:** when adding manual sidecars, put their relative paths in `SKILL.md` and keep the package-data globs for `reference/**/*` / `assets/**/*` so wheels contain them (`pyproject.toml:81-86`). - **Telegram private reverse-channel tool:** `telegram/server.py` `list_tools` advertises only the public `telegram` tool (validated against `SCHEMA` by the mcp library's default `validate_input=True`). Its `build_server._call_tool` also accepts one **unlisted** private tool name, `_PRIVATE_TASK_CARD_TOOL = "_lingtai_telegram_task_card"` (`telegram/server.py:64`), used solely by the kernel to project the live Task Card; being unlisted, it skips public-schema validation yet still reaches the handler, which forces `action="_task_card_update"` before `manager.handle` so the hidden route cannot invoke any public action. The mechanism, kernel caller (`_TASK_CARD_TOOL`), and regression tests are described in `src/lingtai/tools/mcp/ANATOMY.md`. -- **Resident Task Card singleton, one per account+chat; create is update-first (Jason #6665/#6667, #6894/#6899).** `_handle_task_card_update` (`telegram/manager.py:1374`) dispatches create/update/finalize. Because the kernel's automatic task-card context is turn/request-local, every new BaseAgent tool batch/turn re-issues `create`; `_task_card_create` (`telegram/manager.py:1402`) is therefore **update-first** so the singleton card is edited in place and never flickers: it reads the persisted resident id (`_get_resident_task_card`, `telegram/manager.py:1470`) and, when one exists, edits that resident through Telegram (`update_progress_message`) and returns the **same** compound id — sending nothing new and deleting nothing. A replacement send/delete happens only as fail-open recovery: with no resident it sends and persists the first card (`_set_resident_task_card`, `telegram/manager.py:1478`); if the persisted message genuinely cannot be edited it calls the shared `_recover_task_card_by_replacement` (`telegram/manager.py:1446`), which sends the replacement first, then persists the new id and best-effort deletes the exact stale `account:chat:message` (`_delete_task_card_message`, `telegram/manager.py:1489`). A failed replacement send preserves the old card and its id and deletes nothing; a delete failure is fail-open and never rolls back the new id. `_task_card_update` (`telegram/manager.py:1505`) recovers a deleted active card through the same `_recover_task_card_by_replacement` helper. `_task_card_finalize` (`telegram/manager.py:1520`) freezes the card on its concrete last batch (rows + `✓` markers + final elapsed) with no generic overall `DONE` subject; the legacy scalar form keeps `✅ TASK CARD · DONE`. -- **Task Card render: rows, heartbeat elapsed, fixed footer, one card-level time line.** `_format_task_card_text` (`telegram/manager.py:1546`) renders the batched multi-row form via `_format_rows_task_card_text` (`telegram/manager.py:1589`): one line per tool call (`tool.action`, redacted reasoning, own whole-second elapsed via `_format_elapsed` at `telegram/manager.py:1721`, `✓` when done) with **no** per-row inline timestamp. The card carries a single card-level time line instead (Jason #6894/#6899): `时间 HH:MM:SS UTC±HH` (`_TASK_CARD_TIME_PREFIX` at `telegram/manager.py:76`) rendered as the card's **final standalone line after the footer**, sourced from the first non-empty `started_at` in original row order and omitted entirely when no row carries a usable stamp; its exact text is counted in the reasoning-excerpt budget. Redaction runs on each row before any excerpt/trim, and every row always stays represented (rows are never dropped; only per-row reasoning excerpts shrink). The `_TASK_CARD_TEXT_LIMIT` (3500) budget bounds that reasoning-excerpt shrinkage only, **not** the whole render: fixed per-row scaffolding is unbounded in row count, so a very large operator-set `LINGTAI_TASK_CARD_MAX_TOOL_ROWS` can push the render above the budget (and above Telegram's transport limit) — by design the code neither drops requested rows nor truncates the final string. Both the running and frozen renders carry the fixed `_TASK_CARD_FOOTER` (`telegram/manager.py:66`, "⚠️ Progress only — don't reply to this Task Card."). The kernel owns the batch/timer: BaseAgent's pre-dispatch hook builds one row per call and starts a 0.5s monotonic heartbeat (elapsed floored to whole seconds, so half-second frames read 0s, 0s, 1s, 1s, 2s), captures each tool's local start instant **once** into an immutable `started_at` string (`_capture_task_card_started_at`/`_format_task_card_timestamp` at `base_agent/__init__.py:2432,2526`, separate from the monotonic elapsed clock, so heartbeats never change it and parallel rows keep their own), the result hook freezes the completed row, and the payload the reverse channel carries is the `rows` list (see `base_agent/__init__.py` and `src/lingtai/tools/mcp/ANATOMY.md`). +- **Resident Task Card singleton, one per account+chat; create is update-first (Jason #6665/#6667, #6894/#6899).** `_handle_task_card_update` (`telegram/manager.py:1377`) dispatches create/update/finalize. Because the kernel's automatic task-card context is turn/request-local, every new BaseAgent tool batch/turn re-issues `create`; `_task_card_create` (`telegram/manager.py:1405`) is therefore **update-first** so the singleton card is edited in place and never flickers: it reads the persisted resident id (`_get_resident_task_card`, `telegram/manager.py:1473`) and, when one exists, edits that resident through Telegram (`update_progress_message`) and returns the **same** compound id — sending nothing new and deleting nothing. A replacement send/delete happens only as fail-open recovery: with no resident it sends and persists the first card (`_set_resident_task_card`, `telegram/manager.py:1481`); if the persisted message genuinely cannot be edited it calls the shared `_recover_task_card_by_replacement` (`telegram/manager.py:1449`), which sends the replacement first, then persists the new id and best-effort deletes the exact stale `account:chat:message` (`_delete_task_card_message`, `telegram/manager.py:1492`). A failed replacement send preserves the old card and its id and deletes nothing; a delete failure is fail-open and never rolls back the new id. `_task_card_update` (`telegram/manager.py:1508`) recovers a deleted active card through the same `_recover_task_card_by_replacement` helper. `_task_card_finalize` (`telegram/manager.py:1523`) freezes the card on its concrete last batch (rows + `✓` markers + final elapsed) with no generic overall `DONE` subject; the legacy scalar form keeps `✅ TASK CARD · DONE`. +- **Task Card render: rows, heartbeat elapsed, fixed footer, one card-level time line.** `_format_task_card_text` (`telegram/manager.py:1549`) renders the batched multi-row form via `_format_rows_task_card_text` (`telegram/manager.py:1592`): one line per tool call (`tool.action`, redacted reasoning, own whole-second elapsed via `_format_elapsed` at `telegram/manager.py:1724`, `✓` when done) with **no** per-row inline timestamp. The card carries a single card-level time line instead (Jason #6894/#6899): `时间 HH:MM:SS UTC±HH` (`_TASK_CARD_TIME_PREFIX` at `telegram/manager.py:77`) rendered as the card's **final standalone line after the footer**, sourced from the first non-empty `started_at` in original row order and omitted entirely when no row carries a usable stamp; its exact text is counted in the reasoning-excerpt budget. Redaction runs on each row before any excerpt/trim, and every row always stays represented (rows are never dropped; only per-row reasoning excerpts shrink). The `_TASK_CARD_TEXT_LIMIT` (3500) budget bounds that reasoning-excerpt shrinkage only, **not** the whole render: fixed per-row scaffolding is unbounded in row count, so a very large operator-set `LINGTAI_TASK_CARD_MAX_TOOL_ROWS` can push the render above the budget (and above Telegram's transport limit) — by design the code neither drops requested rows nor truncates the final string. Both the running and frozen renders carry the fixed `_TASK_CARD_FOOTER` (`telegram/manager.py:67`, "⚠️ Progress only — don't reply to this Task Card."). The kernel owns the batch/timer: BaseAgent's pre-dispatch hook builds one row per call and starts a 0.5s monotonic heartbeat (elapsed floored to whole seconds, so half-second frames read 0s, 0s, 1s, 1s, 2s), captures each tool's local start instant **once** into an immutable `started_at` string (`_capture_task_card_started_at`/`_format_task_card_timestamp` at `base_agent/__init__.py:2446,2540`, separate from the monotonic elapsed clock, so heartbeats never change it and parallel rows keep their own), the result hook freezes the completed row, and the payload the reverse channel carries is the `rows` list (see `base_agent/__init__.py` and `src/lingtai/tools/mcp/ANATOMY.md`). diff --git a/src/lingtai/mcp_servers/telegram/manager.py b/src/lingtai/mcp_servers/telegram/manager.py index 35dd70d5..95ae4a49 100644 --- a/src/lingtai/mcp_servers/telegram/manager.py +++ b/src/lingtai/mcp_servers/telegram/manager.py @@ -236,6 +236,9 @@ def _transcribe_voice(audio_path: str, model_name: str = "base") -> dict: log.warning("Voice transcription failed: %s", e) return {"error": str(e)} +SUPPORTED_SEND_MEDIA_TYPES = ("photo", "document") + + SCHEMA = { "type": "object", "properties": { @@ -285,11 +288,11 @@ def _transcribe_voice(audio_path: str, model_name: str = "base") -> dict: "media": { "type": "object", "properties": { - "type": {"type": "string", "enum": ["photo", "document", "voice", "audio"]}, + "type": {"type": "string", "enum": list(SUPPORTED_SEND_MEDIA_TYPES)}, "path": {"type": "string"}, }, "description": ( - "Media attachment: {type: 'photo'|'document'|'voice'|'audio', path: '/path/to/file'}. " + "Media attachment: {type: 'photo'|'document', path: '/path/to/file'}. " "For charts, HTML/SVG/PNG reports, CSVs, PDFs, and other generated artifacts that should arrive as an intact file, use type='document'. " "Use type='photo' only for native inline photo previews; Telegram photo delivery can crop, compress, thumbnail, or otherwise display text-heavy charts poorly. " "Do not paste local file paths in message text as a substitute for attaching the file." diff --git a/src/lingtai/tools/mcp/ANATOMY.md b/src/lingtai/tools/mcp/ANATOMY.md index e7f830a6..35a950a6 100644 --- a/src/lingtai/tools/mcp/ANATOMY.md +++ b/src/lingtai/tools/mcp/ANATOMY.md @@ -158,7 +158,7 @@ mcp/licc.py (client-side producer; mirrors inbox.py's consumer) - **Pure presentation:** The capability never writes to the registry file. It only reads and renders. - **Private reverse channel via an unlisted MCP tool name (kernel-driven):** A server may expose a private capability through a *tool name that ``list_tools`` never returns* — e.g. Telegram's ``_lingtai_telegram_task_card``. Because the name is unlisted, ``mcp.server.lowlevel.Server.call_tool`` (default ``validate_input=True``) finds no cached tool definition, **skips** input validation, and still invokes the registered handler; the public ``telegram`` name keeps its default schema validation unchanged. The kernel invokes the private name directly through ``MCPClient.call_tool(tool_name, args)`` on a client reference obtained from ``agent._mcp_clients_by_tool`` (a stable ``tool_name → client`` mapping built at MCP registration time in ``agent.connect_mcp`` / ``agent.connect_mcp_http`` at ``src/lingtai/agent.py:933-938,990-995``), which bypasses ``ToolExecutor`` entirely (no recursion through the tool dispatch pipeline). The kernel sends **no** public ``action``; when the private name arrives, the server copies the arguments and **forces** ``action="_task_card_update"`` before ``manager.handle`` (``src/lingtai/mcp_servers/telegram/server.py``, ``build_server._call_tool`` branch on ``_PRIVATE_TASK_CARD_TOOL``), so the hidden route can only ever project the card — it cannot be repurposed for any public Telegram action. The public ``telegram`` name with a guessed ``action="_task_card_update"`` is still rejected by the unchanged public ``SCHEMA`` (the action is absent from its enum), and any other unlisted name is rejected by the handler. This is strictly smaller than validating manually: no ``validate_input=False`` and no re-implemented jsonschema check. The kernel-side name is mirrored as ``_TASK_CARD_TOOL`` in ``lingtai.kernel.base_agent`` (a literal, since the kernel must not import ``mcp_servers``); the two must stay in sync. Regression coverage that drives the real ``Server.request_handlers[CallToolRequest]`` boundary lives in ``tests/test_telegram_task_card_transport.py``. - **Task Card batch/heartbeat hooks (``_on_tool_pre_dispatch_hook`` + ``_on_tool_result_hook``):** ``BaseAgent`` exposes two optional lifecycle hooks that ``ToolExecutor`` calls around each tool. The pre-dispatch hook (``src/lingtai/kernel/base_agent/__init__.py:2342``) receives ``(tool_name, tool_args, tool_call_id)`` and builds **one card row per tool call** appended in actual pre-dispatch order: because parallel pre-dispatch hooks are serialized before any future starts (``_execute_parallel`` Phase 2 at ``src/lingtai/kernel/tool_executor.py:1424-1432``), that order is deterministic. The card keeps a **rolling window of the newest N ordinary tool rows** where N is ``_task_card_max_tool_rows()`` (``__init__.py:80``, env-configurable via ``LINGTAI_TASK_CARD_MAX_TOOL_ROWS``, default 1) — a tool arriving with no active tool predecessor opens a new epoch (bumping a generation counter and starting the heartbeat), but completed tool rows are **not** cleared down to only the current tool; instead ``_cap_task_card_tool_rows`` (``__init__.py:2412``) evicts the oldest surplus tool rows in place while retaining the API-error row. The post-dispatch result hook (``_on_tool_result_hook`` at ``src/lingtai/kernel/base_agent/__init__.py:2279``) is the completion signal: ``ToolExecutor`` fires it on the orchestrating thread in input order in the sequential, parallel, tool-error, intercept, and raised-dispatch-exception paths, and ``_freeze_task_card_row`` (``__init__.py:2309``) freezes the matching row (final whole-second elapsed + ``done`` marker) while other rows keep ticking; a row already evicted from the window has no matching ``call_id``, so its late result is a no-op and cannot mutate the window. Rendering (create lazily, else edit the same card) is ``_render_task_card`` (``__init__.py:2443``); the 0.5s monotonic heartbeat is ``_task_card_heartbeat_tick`` (``__init__.py:2724``) driven by ``_start_task_card_heartbeat`` (``__init__.py:2759``), with elapsed floored to whole seconds by ``_task_card_elapsed`` (``__init__.py:2497``) so the display shows integer seconds (half-second frames read ``0s, 0s, 1s, 1s, 2s``) with no decimal point. The single reused heartbeat thread is kept safe against stale writes by reading ``ctx["rows"]``/``ctx["generation"]`` freshly under the lock each tick and by the all-rows-done early return (which never overwrites the frozen last-behavior state); the tick's explicit ``generation`` parameter is retained for the stale-timer/test defense that simulates an old epoch's tick. Both hooks are best-effort and never block or mutate a tool result: the reverse-call MCP client reports tool-level failures as an error *dict* (and can also raise), so the hooks inspect the payload via ``_task_card_result_error`` / ``_task_card_result_message_id`` (no fake success), stay fail-open, and are made observable through the content-free ``_log_task_card_reverse_failure`` / ``_log_task_card_reverse_exception`` warnings (only ``phase``/``tool`` + result ``status`` or exception *class*, never reasoning, chat id, account, card id, or provider text). ``ToolExecutor._invoke_result_hook`` (``src/lingtai/kernel/tool_executor.py:1043``) isolates the result hook so a raising hook can never be caught by the dispatch error handler and turned into a fake error, and ``_invoke_result_hook_observe`` (``tool_executor.py:1071``) runs it on the intercept path without letting it override the tool's intercept. Turn lifecycle cleanup lives in ``_handle_request`` / ``_handle_tc_wake`` (``src/lingtai/kernel/base_agent/turn.py:1123,1332``) via ``try/finally`` blocks that call ``_teardown_telegram_task_card``, which stops the heartbeat and freezes the resident card on its concrete last batch (no generic overall ``DONE``). The per-account+chat resident-card singleton and its ``state.json`` persistence live in the Telegram adapter (see ``src/lingtai/mcp_servers/ANATOMY.md``). -- **LLM/provider API errors surfaced to the automatic Task Card (observe-only):** provider API failures in a Telegram-originated turn (e.g. a ``429 usage_limit_reached`` before any tool, or an error after tool results) are surfaced to the same automatic card even though no tool call produced them. The AED loop in ``_run_loop`` catches provider errors on the **orchestrating** thread (``turn.py`` ``except Exception`` block) and reports through the fail-open module wrappers ``_report_api_error_to_task_card`` (``src/lingtai/kernel/base_agent/turn.py:130``) and ``_recover_api_error_on_task_card`` (``turn.py:149``): the transient-retry path (``turn.py:774``), the deterministic-AED path (``turn.py:832``), and the success path (``turn.py:678``, marks ``recovered``). Terminal truth is not merely ``aed_attempts >= max`` — the deterministic path reports the row ``terminal`` only when no next recovery remains (i.e. no viable, not-yet-attempted preset fallback), so a successful preset auto-fallback never renders a false ``failed`` before ``_perform_refresh``; if preset activation itself raises, the same row is then frozen ``error`` before ASLEEP. Because ``_handle_request`` tears down the card context in a ``finally`` before the outer AED catch runs, an **after-tool continuation** provider error is additionally reported from the continuation-send ``except`` while the context is still live (``turn.py:1903``, observe-only/fail-open, re-raised unchanged); the stable single-row upsert keeps that safe against a later AED report. Both wrappers ``getattr`` the optional BaseAgent hook and swallow any failure, so reporting can never change the retry/fallback decision, the eventual success/failure, or token accounting. ``BaseAgent._report_task_card_api_error`` (``src/lingtai/kernel/base_agent/__init__.py:2648``) upserts **one stable API-error row per turn/retry sequence** (sentinel ``_TASK_CARD_API_ROW_ID`` at ``__init__.py:2582``) into the current batch and renders through the same ``_render_task_card`` create-or-edit path — repeated failures update the row, never a card per error — and ``_recover_task_card_api_error`` (``__init__.py:2695``) freezes it ``recovered`` while preserving that an error happened. The surfaced summary is sanitized to the numeric status (``_task_card_api_status`` at ``__init__.py:2604``, reads only structured ``status_code``/``status``/``code``/``response.status_code``) plus a machine code strictly allow-listed against ``_TASK_CARD_SAFE_API_CODES`` (``__init__.py:2589``) via ``_task_card_api_code`` (``__init__.py:2623``, reads only structured ``code``/``body`` fields) — never ``str(exc)``, so arbitrary message/body, URLs, headers, tokens, prompts, tracebacks, or paths can never reach the card. The Telegram adapter renders the API-error row with ``TelegramManager._format_api_error_line`` (``src/lingtai/mcp_servers/telegram/manager.py:1689``); the row carries the fixed no-reply footer and obeys the same update-first resident-card singleton semantics (edit the persisted resident in place, replacement send/delete only as fail-open recovery — see ``src/lingtai/mcp_servers/ANATOMY.md``). A non-Telegram/no-route turn (no ``_telegram_task_card_context``) is a no-op. +- **LLM/provider API errors surfaced to the automatic Task Card (observe-only):** provider API failures in a Telegram-originated turn (e.g. a ``429 usage_limit_reached`` before any tool, or an error after tool results) are surfaced to the same automatic card even though no tool call produced them. The AED loop in ``_run_loop`` catches provider errors on the **orchestrating** thread (``turn.py`` ``except Exception`` block) and reports through the fail-open module wrappers ``_report_api_error_to_task_card`` (``src/lingtai/kernel/base_agent/turn.py:130``) and ``_recover_api_error_on_task_card`` (``turn.py:149``): the transient-retry path (``turn.py:774``), the deterministic-AED path (``turn.py:832``), and the success path (``turn.py:676``, marks ``recovered``). Terminal truth is not merely ``aed_attempts >= max`` — the deterministic path reports the row ``terminal`` only when no next recovery remains (i.e. no viable, not-yet-attempted preset fallback), so a successful preset auto-fallback never renders a false ``failed`` before ``_perform_refresh``; if preset activation itself raises, the same row is then frozen ``error`` before ASLEEP. Because ``_handle_request`` tears down the card context in a ``finally`` before the outer AED catch runs, an **after-tool continuation** provider error is additionally reported from the continuation-send ``except`` while the context is still live (``turn.py:1895-1903``, observe-only/fail-open, re-raised unchanged); the stable single-row upsert keeps that safe against a later AED report. Both wrappers ``getattr`` the optional BaseAgent hook and swallow any failure, so reporting can never change the retry/fallback decision, the eventual success/failure, or token accounting. ``BaseAgent._report_task_card_api_error`` (``src/lingtai/kernel/base_agent/__init__.py:2662``) upserts **one stable API-error row per turn/retry sequence** (sentinel ``_TASK_CARD_API_ROW_ID`` at ``__init__.py:2596``) into the current batch and renders through the same ``_render_task_card`` create-or-edit path — repeated failures update the row, never a card per error — and ``_recover_task_card_api_error`` (``__init__.py:2709``) freezes it ``recovered`` while preserving that an error happened. The surfaced summary is sanitized to the numeric status (``_task_card_api_status`` at ``__init__.py:2618``, reads only structured ``status_code``/``status``/``code``/``response.status_code``) plus a machine code strictly allow-listed against ``_TASK_CARD_SAFE_API_CODES`` (``__init__.py:2603``) via ``_task_card_api_code`` (``__init__.py:2637``, reads only structured ``code``/``body`` fields) — never ``str(exc)``, so arbitrary message/body, URLs, headers, tokens, prompts, tracebacks, or paths can never reach the card. The Telegram adapter renders the API-error row with ``TelegramManager._format_api_error_line`` (``src/lingtai/mcp_servers/telegram/manager.py:1692``); the row carries the fixed no-reply footer and obeys the same update-first resident-card singleton semantics (edit the persisted resident in place, replacement send/delete only as fail-open recovery — see ``src/lingtai/mcp_servers/ANATOMY.md``). A non-Telegram/no-route turn (no ``_telegram_task_card_context``) is a no-op. ## Dependencies diff --git a/tests/test_telegram_send_media_contract.py b/tests/test_telegram_send_media_contract.py new file mode 100644 index 00000000..2d686887 --- /dev/null +++ b/tests/test_telegram_send_media_contract.py @@ -0,0 +1,88 @@ +"""Contract tests for Telegram's advertised outbound media support.""" + +from pathlib import Path + +import pytest + +from lingtai.mcp_servers.telegram.manager import ( + SCHEMA, + SUPPORTED_SEND_MEDIA_TYPES, + TelegramManager, +) +from tests._notification_store_helpers import notification_store_for + + +class _Account: + alias = "bot" + + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + + def send_photo(self, _chat_id, path, **_kwargs): + self.calls.append(("photo", path)) + return {"message_id": 1} + + def send_document(self, _chat_id, path, **_kwargs): + self.calls.append(("document", path)) + return {"message_id": 2} + + +class _Service: + def __init__(self) -> None: + self.account = _Account() + + def get_account(self, alias): + assert alias == "bot" + return self.account + + +def _manager(tmp_path: Path) -> tuple[TelegramManager, _Account]: + service = _Service() + manager = TelegramManager( + service, + working_dir=tmp_path, + on_inbound=lambda _: None, + notification_store=notification_store_for(tmp_path), + ) + return manager, service.account + + +def test_send_schema_advertises_exactly_runtime_supported_media_types(): + advertised = SCHEMA["properties"]["media"]["properties"]["type"]["enum"] + + assert advertised == list(SUPPORTED_SEND_MEDIA_TYPES) == ["photo", "document"] + + +@pytest.mark.parametrize("media_type", SUPPORTED_SEND_MEDIA_TYPES) +def test_each_advertised_media_type_dispatches(tmp_path: Path, media_type: str): + media_path = tmp_path / f"attachment.{media_type}" + media_path.write_bytes(b"content") + manager, account = _manager(tmp_path) + + result = manager._send({ + "account": "bot", + "chat_id": 123, + "media": {"type": media_type, "path": str(media_path)}, + }) + + assert result == { + "status": "sent", + "message_id": f"bot:123:{1 if media_type == 'photo' else 2}", + } + assert account.calls == [(media_type, str(media_path))] + + +@pytest.mark.parametrize("media_type", ["voice", "audio"]) +def test_unadvertised_media_types_remain_rejected(tmp_path: Path, media_type: str): + media_path = tmp_path / "unsupported-media" + media_path.write_bytes(b"content") + manager, account = _manager(tmp_path) + + result = manager._send({ + "account": "bot", + "chat_id": 123, + "media": {"type": media_type, "path": str(media_path)}, + }) + + assert result == {"error": f"Unknown media type: {media_type}"} + assert account.calls == []