diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml index 6eeebc9848..cb487f67a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-20-routed-model-context-and-compaction-policy.md: b637ba24d4ba5fc25c8cdd515a821ee97883a326 -2026-07-20-routed-model-context-and-compaction-policy.zh.md: 084e762ec29ddc0aecb0bf422c147b9d3122726b +2026-07-20-routed-model-context-and-compaction-policy.md: b00c744e4ac79983a1f492d710e7b1542278c4f4 +2026-07-20-routed-model-context-and-compaction-policy.zh.md: 88e26aa7a99e8deb434d9a8fd80784be410a999a diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md index b637ba24d4..b00c744e4a 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md @@ -14,9 +14,9 @@ Neither obvious configuration owner is sufficient. Compact-basic is optional and ### Adapters own exact-route capacity -`LlmAdapter.resolveModelContext(provider, model)` optionally returns `LlmModelContext` for one exact route. `LlmService.resolveModelContext()` selects the registered route owner, validates a positive integer `contextWindow`, and returns a detached value. The query is independent of `listModels()`: an unlisted dynamic model may have capacity metadata, and `undefined` means only that the adapter cannot describe capacity. +`LlmAdapter.resolveModel(provider, model, signal?)` returns aggregate metadata for one exact route, with optional `LlmModelContext` under its `context` field. `LlmService.resolveModelInfo()` selects the registered route owner, validates a positive integer `contextWindow`, and returns detached metadata. The query is independent of `listModels()`: an unlisted dynamic model may have capacity metadata, and an absent `context` means only that the adapter cannot describe capacity. -The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model plus an adapter-wide `defaultContextWindow`. Exact model capacity wins; an entry without capacity and an unlisted pass-through id inherit the adapter default, or return `undefined` when it is absent. The two built-in model entries each publish an exact 128,000-token capacity. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model. +The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model plus an adapter-wide `defaultContextWindow`. Exact model capacity wins; an entry without capacity and an unlisted pass-through id inherit the adapter default, or omit `context` when it is absent. The two built-in model entries each publish an exact 128,000-token capacity. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model. ### Token measurement remains model-agnostic diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md index 084e762ec2..88e26aa7a9 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md @@ -14,9 +14,9 @@ Status: implemented ### 适配器拥有精确路由容量 -`LlmAdapter.resolveModelContext(provider, model)` 可以为一条精确路由返回 `LlmModelContext`。`LlmService.resolveModelContext()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回分离值。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而 `undefined` 只表示适配器无法描述容量。 +`LlmAdapter.resolveModel(provider, model, signal?)` 返回一条精确路由的聚合元数据,其中可选的 `LlmModelContext` 位于 `context` 字段下。`LlmService.resolveModelInfo()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回分离的元数据。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而缺少 `context` 只表示适配器无法描述容量。 -手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则返回 `undefined`。两个内置模型项都公开精确的 128,000 token 容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。 +手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`,并支持适配器级 `defaultContextWindow`。精确模型容量优先;未提供容量的模型项与未列出的透传 id 会继承适配器默认值,若默认值也不存在则省略 `context`。两个内置模型项都公开精确的 128,000 token 容量。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。 ### Token 计量保持模型无关 diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml new file mode 100644 index 0000000000..e9adb2cd8b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-adapter-owned-reasoning-effort-capabilities.md: cc66e4ec151fcc04445a91f4a3527cbddd130c33 +2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md: e0d28e1aca370068478e8fb1704defeaac3ab351 diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md new file mode 100644 index 0000000000..cc66e4ec15 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md @@ -0,0 +1,33 @@ +# Agent Note: Adapter-owned reasoning effort capabilities + +Status: implemented + +English | [中文](2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md) + +## Problem + +Reasoning strength was adapter configuration only, so a conversation could not discover or change the selected model's supported levels between requests. Promoting one adapter's level union into `dsh-llm` would make every provider and model adopt names it may not support, while a provider-specific options bag would make the loop unable to validate or durably reconstruct the effective request. + +## Decision + +`dsh-llm` represents a reasoning effort as the opaque branded `ReasoningEffortId`. One adapter-owned `resolveModel(provider, model, signal?)` query returns `LlmResolvedModelInfo`: exact model identity plus optional context and reasoning metadata. `LlmService.resolveModelInfo()` validates and detaches that aggregate. When present, `reasoning.efforts` is a non-empty ordered list of ids with display metadata and may name one configured default. The core requires an explicit or configured effort to appear exactly in that list and never clamps or aliases a value. + +`LlmCallConfig` and `GenerateOptions` carry the optional effort. The agent loop prepares the post-`agent/request` config under the active turn signal before writing `request/header`, so defaults and dynamic changes are model-visible only after becoming durable facts. The prepared call retains the exact adapter registration across asynchronous exact-model resolution, durable header logging, and dispatch; direct `LlmService.stream()` calls likewise capture their final registration before awaiting resolution. A route with no registered adapter retains its proposed config so an `llm/stream` middleware can own and short-circuit it; terminal dispatch still rejects an unhandled route. A resumed loop retains the logged effort only when its initial provider/model route is unchanged; a route change discards the previous model's opaque id. + +The native DeepSeek adapter advertises `off`, `high`, and `max` when deployment policy permits thinking, and defaults to the configured effort or `high`. Its adapter-owned `off` maps to `thinking.type: disabled` with no `reasoning_effort`; `high` and `max` enable thinking and carry their official wire effort. A `thinking: disabled` deployment publishes only `off` and rejects attempts to enable thinking before provider I/O. The pi-ai adapter publishes each exact model's `getSupportedThinkingLevels()` result unchanged, including `off`, preserves an absent profile default as a provider default, and leaves provider wire-value mapping inside pi-ai. Its common stream options represent `off` by omitting `reasoning`, as required by pi-ai's own API. + +## Alternatives considered + +**Define the pi-ai `ThinkingLevel` union in core.** Rejected because current pi-ai canonical names are an adapter implementation detail; a future provider can expose a different identifier without requiring a core release. + +**Carry an untyped provider options object.** Rejected because the loop could neither validate a selected value nor put a stable provider-neutral fact in the request header. + +**Clamp unsupported levels.** Rejected because a silent substitution makes the user's selected control differ from the logged request intent and hides stale deployment configuration. + +**Normalize every adapter to a core-owned level list or remove `off`.** Rejected because the selectable vocabulary belongs to the exact model capability. A client can render an adapter's `off` option without requiring every adapter to expose it. + +## Consequences + +Clients can query one exact route once and render its identity, context capacity, and adapter-owned reasoning choices without knowing a global enum or synthesizing `off`. Adapter configuration remains the deployment-default and policy owner, while `agent/request` can replace the effective effort on each step within that policy. Invalid exact identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`; unsupported explicit or configured values fail with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. + +The aggregate exact-model query is asynchronous and may fail for adapters backed by authoritative catalogs. Its optional signal is the caller's cancellation boundary; an asynchronous adapter must settle promptly after abort so loop disposal can reach quiescence. Keyless service, adapter, loop, session, and request-header tests pin validation, defaulting, dynamic changes, logging, resume behavior, HMR registration ownership, and cancellation; runnable snapshots pin the resolved effort in real assembled request headers, while key-gated adapter tests exercise provider serialization. diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md new file mode 100644 index 0000000000..e0d28e1aca --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md @@ -0,0 +1,33 @@ +# Agent Note:适配器持有的推理强度能力 + +Status: implemented + +[English](2026-07-24-adapter-owned-reasoning-effort-capabilities.md) | 中文 + +## 问题 + +推理强度过去只能在适配器中配置,因此对话无法在多次请求之间发现或更改所选模型支持的等级。若将某个适配器的等级联合类型提升到 `dsh-llm`,所有提供方和模型都必须采用一套自身可能并不支持的名称;若改用提供方特有的 options 对象,主循环又无法校验最终生效的请求,也无法通过持久化记录准确重建该请求。 + +## 决策 + +`dsh-llm` 使用不透明的品牌类型 `ReasoningEffortId` 表示推理强度。由适配器持有的单次 `resolveModel(provider, model, signal?)` 查询返回 `LlmResolvedModelInfo`,其中包含确切模型身份以及可选的上下文和推理元数据。`LlmService.resolveModelInfo()` 会校验该聚合结果并返回分离值。`reasoning.efforts` 存在时,是包含展示元数据的非空有序 ID 列表,并可指定一个由配置确定的默认值。核心要求显式指定或配置指定的推理强度与列表中的某个 ID 完全一致,且绝不自动调整或为值提供别名。 + +`LlmCallConfig` 和 `GenerateOptions` 携带可选的推理强度。agent loop(智能体循环)在活跃轮次信号的控制下准备 `agent/request` 处理完成后的配置,再写入 `request/header`,因此默认值和动态变更只有成为持久化事实后才对模型可见。准备完成的调用在异步确切模型解析、请求头持久记录和分派全程保留同一项确切的适配器注册;直接调用 `LlmService.stream()` 时,也会在等待解析前捕获最终的适配器注册。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 中间件可以接管并短路该请求;若仍未得到处理,最终分发会拒绝该路由。恢复后的主循环仅在初始提供方/模型路由未变时保留日志中记录的推理强度;如果路由发生变化,则丢弃上一模型的不透明 ID。 + +当部署策略允许思考时,原生 DeepSeek 适配器声明 `off`、`high` 和 `max`,默认使用配置指定的推理强度,若未配置则使用 `high`。由适配器持有的 `off` 映射为 `thinking.type: disabled`,且不带 `reasoning_effort`;`high` 和 `max` 会启用思考并携带各自的官方协议强度值。配置为 `thinking: disabled` 的部署仅声明 `off`,并会在提供方 I/O 前拒绝启用思考的尝试。pi-ai 适配器原样发布每个确切模型的 `getSupportedThinkingLevels()` 结果,其中包括 `off`;profile 未指定默认值时保留提供方默认行为,并将提供方协议值的映射留在 pi-ai 内部。按照 pi-ai 自身 API 的要求,其通用流选项通过省略 `reasoning` 来表示 `off`。 + +## 备选方案 + +**在核心中定义 pi-ai 的 `ThinkingLevel` 联合类型。** 不予采纳:pi-ai 当前的规范名称属于适配器实现细节;未来的提供方可以暴露不同的标识符,而无需为此发布新的核心版本。 + +**携带无类型约束的提供方 options 对象。** 不予采纳:主循环既无法校验选定值,也无法在请求头中写入稳定且与提供方无关的事实。 + +**自动调整不支持的等级。** 不予采纳:静默替换会导致用户选定的控制项与日志记录的请求意图不一致,还会掩盖陈旧的部署配置。 + +**将每个适配器规范化为核心持有的等级列表,或移除 `off`。** 不予采纳:可选值集合属于确切模型的能力。客户端可以渲染某个适配器的 `off` 选项,而无需要求所有适配器都暴露该选项。 + +## 影响 + +客户端只需查询一次确切路由,即可渲染其身份、上下文容量和由适配器持有的推理选项,而无需了解全局枚举或自行合成 `off`。适配器配置仍是部署默认值和策略的归属方,`agent/request` 则可以在该策略范围内为每个步骤替换实际生效的推理强度。确切身份、上下文或推理元数据无效时,分别抛出 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING`;显式指定或配置指定的值不受支持时,会在提供方 I/O 前抛出 `UNSUPPORTED_REASONING_EFFORT`。 + +确切模型元数据的聚合查询采用异步方式,并且对于由权威目录支持的适配器可能失败。可选信号构成调用方的取消边界;异步适配器必须在信号中止后迅速完成结算,使主循环的资源释放达到完全停稳。无密钥的服务、适配器、主循环、会话和请求头测试为校验、默认值解析、动态变更、日志记录、恢复行为、HMR(热模块替换)期间的注册所有权和取消提供回归保障;可运行快照锁定实际组装请求头中的已解析推理强度,仅在有密钥时运行的适配器测试则覆盖提供方序列化。 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index d7babf16b3..be023bf4c7 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-29-todo-write-tool.md: df1bee2801b0e01b290b63f6edbe2e5b1be80cb7 -2026-06-29-todo-write-tool.zh.md: 7fa5cb2aad2b32ef0662df04ff6576be14a3a8e7 +2026-06-29-todo-write-tool.md: 760373d64f462e3717a174d5793e6d47ab76b0b4 +2026-06-29-todo-write-tool.zh.md: fd29e6049e6a729c2c7e78860ad7c065422da60e diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md index df1bee2801..760373d64f 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md @@ -10,7 +10,7 @@ The harness gives the model bash and subagent tools but no way to record a struc ## Decision -Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Interactive hosts render from the durable event; the TUI folds it directly, while the [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately omits todo presentation. +Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Interactive hosts render from the durable event: the TUI folds it directly, the web client projects it into `ConversationSnapshot.todos` ([web todo display](2026-07-23-web-todo-display.md)), while the [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately omits todo presentation. ### Whole-list replace, three-state status @@ -18,7 +18,7 @@ The model sends the entire list every call; the new list replaces the old (last- ### State on the session log, not a service -The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and resume reconstruction for free: a reopened session re-derives the current list from the latest `todo/write`, with no separate persistence backend, in-memory service to rehydrate, or extra wiring. An in-memory `ctx.todos` service would have to reinvent all of that. +The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and resume reconstruction for free: a reopened session re-derives the current list from the latest `todo/write`, with no separate persistence backend, in-memory service to rehydrate, or extra wiring. An in-memory `ctx.todos` service would have to reinvent all of that. (Full-log consumers get this reconstruction outright; the web client's paged window gets it from the tail history page's host-computed projection — see the [web todo display note](2026-07-23-web-todo-display.md).) ### NOT a surface event diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md index 7fa5cb2aad..fd29e6049e 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -10,7 +10,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结 ## 决策 -新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。交互式宿主从持久事件渲染;TUI 直接折叠它,而[仅面向自动化的 ACP(Agent Client Protocol)桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意省略 todo 展示。 +新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。交互式宿主从持久事件渲染:TUI 直接折叠它,web 客户端将其投影进 `ConversationSnapshot.todos`([web todo 展示](2026-07-23-web-todo-display.md)),而[仅面向自动化的 ACP(Agent Client Protocol)桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意省略 todo 展示。 ### 整列表替换,三态 status @@ -18,7 +18,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结 ### 状态在会话日志上,而非服务 -列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从最新的 `todo/write` 重新推导当前列表,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。 +列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从最新的 `todo/write` 重新推导当前列表,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。(全量 log 消费者直接获得这份重建;web 客户端的分页窗口则从尾页 history 携带的 host 计算投影获得——见 [web todo 展示 Note](2026-07-23-web-todo-display.md)。) ### 不是 surface 事件 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 79a4f5e7cc..6d44658ecc 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-dedicated-full-screen-tui-front-door.md: aac67ffec89606d04d5abfd233d0469e2241b102 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 0f64e2ce14b18d315f75913b82c731e77758e377 +2026-07-17-dedicated-full-screen-tui-front-door.md: 8d7c7b00c8d9b15ea3f2419ed44ca88209e60dad +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 49e9541c9f98c9f3beba11945ff452fc38bd9ede diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index aac67ffec8..8d7c7b00c8 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -22,9 +22,9 @@ The selected front door receives the exact generated or resumed `SessionId` used The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. -Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and shows the selected model; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services. +Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services. -The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model pair per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. +The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. ### Terminal ownership @@ -49,4 +49,4 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t - The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol. - Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor. - Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI. -- Model selection uses adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state. +- Model and reasoning-effort selection use adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 0f64e2ce14..49e9541c9f 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -22,9 +22,9 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 -agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 +agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 -`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个 step 快照一次同一个提供方/模型字段组合,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 +`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 ### 终端所有权 @@ -49,4 +49,4 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 - TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署使用 Headless app 或结构化协议。 - 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 - 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。 -- 模型选择使用适配器提供的目录元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。 +- 模型和推理强度选择使用适配器公布的元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml new file mode 100644 index 0000000000..4bccfa396e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md +2026-07-23-web-todo-display.md: 830f55c86c893c4942a1a9d3b8529395d5f5e38b +2026-07-23-web-todo-display.zh.md: e68928d7eddaaa92ac831722a738ee2002342b38 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md new file mode 100644 index 0000000000..830f55c86c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -0,0 +1,36 @@ +# Agent Note: Web todo display — snapshot side-effect channel + two render surfaces + +Status: implemented + +English | [中文](2026-07-23-web-todo-display.zh.md) + +## Problem + +`todo_write` appends `todo/write` whole-list snapshots to the session log; the TUI renders a persistent plan panel (the automation-only ACP bridge deliberately omits todo presentation). The web client dropped the event entirely: the host mux stream already forwards every session event, but `todo/write` is not a surface type (it never folds into `ConversationSnapshot.nodes`), and no side-effect branch accumulated it — the browser had no consumption point and no display surface. + +## Decision + +Consume `todo/write` as a Session side effect, not a surface node, and render it on two surfaces matching the split the TUI already draws. + +### Side-effect channel, converging with window replay + +`applyEventSideEffects` gains a `todo/write` case (whole list, last write wins). Unlike partial/openCalls, `rebuildDerivedFromWindow` deliberately does NOT reset it: the value is session-level — taken from the tail history page's full-log projection — and an arbitrary window may not contain the latest write, so an older-page prepend keeps it and only an in-window or live write overwrites it. Every `installWindow` caller is a tail request (`doOpen`, its gap re-pull, `repairGap`; `loadOlder` prepends without it), which the host answers with the projection or omits it only when the full log holds no `todo/write` — so an absent field is the authoritative empty list and is assigned as such. That distinction matters on rollback: a live write whose host crashed before persisting leaves the log empty, and preserving the prior value instead would strand the rolled-back plan on screen indefinitely. `ConversationSnapshot.todos` is the read surface. This follows the event's own contract ("log-only UI state; never derived history"): surfacing each write as a conversation node would render superseded lists as if they were still standing. + +### TodoPanel: the durable list as a persistent strip + +The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible with the in-progress item as the collapsed one-line hint; ✓/●/○ glyphs mirror the TUI plan panel. It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. + +### TodoRow: the per-call row through the keyed toolview slot + +The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `conversation.chat.toolview` slot via `ctx.slots.register` — the same seam and load-order posture as the bash sample (`inject: ['slots', 'conversation']`), but a product registration. The summary derives from call args (`N/M done · active item`); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card. + +## Alternatives considered + +- **Fold todo writes into `nodes` as surface entries** — replayed windows would render every superseded list; the event is deliberately not a surface type. +- **Hardcoding the panel inside `ConversationRoot`** — the original landing spot before the input-dock slot existed; the dock is the architecture's home for always-on strips above the composer, and a hardcode bypasses the slot registry's disposal and ordering. +- **Details column for the panel** — the details slot is single-occupant and selection-driven, a different lifetime than an always-on strip. +- **Host-computed view (a todo `ToolEventView`)** — presentation belongs to the client; the wire already carries the whole snapshot in the event payload. + +## Consequences + +Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 65) plus the assembled keyless snapshot (`apps/web/tests/todo-display.snapshot.ts`) pin the full chain (row summary and state, dock panel content, collapse round-trip) over the built client graph. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The TUI panel is untouched (the automation-only ACP bridge deliberately omits todo presentation); the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log latest `todo/write`, computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan even when the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, and resets to empty when a tail response carries no projection. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md new file mode 100644 index 0000000000..e68928d7ed --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -0,0 +1,36 @@ +# Agent Note: Web todo 展示——快照副作用通道 + 两个渲染面 + +Status: implemented + +[English](2026-07-23-web-todo-display.md) | 中文 + +## Problem + +`todo_write` 把 `todo/write` 的整份列表快照追加进会话日志;TUI 渲染一块常驻的 plan 面板(自动化专用的 ACP 桥接刻意不做 todo 呈现)。Web 客户端把这个事件整个丢弃了:host mux 流本已转发每一个会话事件,但 `todo/write` 不是 surface 类型(它从不 fold 进 `ConversationSnapshot.nodes`),也没有任何副作用分支累积它——浏览器既无消费点,也无展示面。 + +## Decision + +把 `todo/write` 当作 Session 副作用消费,而非 surface 节点,并在两个面上渲染它,这两个面正对应 TUI 已经绘制的那套划分。 + +### 副作用通道,与窗口回放收敛 + +`applyEventSideEffects` 新增一个 `todo/write` 分支(整份列表,后写覆盖先写)。与 partial/openCalls 不同,`rebuildDerivedFromWindow` 刻意不重置它:该值是会话级的——取自尾页 history 携带的全量 log 投影——而任意窗口未必包含最近一次写入,因此往前翻页保留它,只有窗口内或实时的写入才会覆盖。`installWindow` 的每个调用方都是尾页请求(`doOpen`、其补洞重拉、`repairGap`;`loadOlder` 只往前拼接、不走它),而 host 对尾页请求要么带上投影、要么仅在全量 log 没有任何 `todo/write` 时省略——因此字段缺失就是权威的空列表,直接照此赋值。这个区分在回滚场景上要紧:实时写入若在 host 持久化前崩溃,log 里就是空的,此时保留旧值会让已回滚的计划永远留在屏幕上。`ConversationSnapshot.todos` 是读取面。这遵循事件自身的契约(「仅日志 UI 状态,绝非派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。 + +### TodoPanel:长驻列表作为一条常驻横条 + +面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠——折叠态以进行中项作为单行提示;✓/●/○ 字形与 TUI plan 面板一致。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 + +### TodoRow:经 keyed toolview slot 的逐调用行 + +专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.register` 注册进 keyed 的 `conversation.chat.toolview` slot——与 bash 样例同一接缝、同一载序姿态(`inject: ['slots', 'conversation']`),但属产品级注册。摘要由调用 args 推导(`N/M done · active item`);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。 + +## Alternatives considered + +- **把 todo 写入作为 surface 条目折叠进 `nodes`**——回放的窗口会渲染每一份已被取代的列表;该事件被刻意设计成非 surface 类型。 +- **面板硬编码进 `ConversationRoot`**——input-dock slot 出现之前的原始落点;dock 是本架构给"composer 上方常开横条"安排的家,硬编码绕开了 slot 注册表的 disposal 与定序。 +- **面板放进 details 列**——details slot 单占用且由选中驱动,生命周期不同于一条常开横条。 +- **host 计算的视图(一个 todo `ToolEventView`)**——呈现属于客户端;协议已在事件载荷里携带整份快照。 + +## Consequences + +回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 65 轮的 fixture(测试前置数据)加 assembled keyless snapshot(`apps/web/tests/todo-display.snapshot.ts`)在构建产物客户端全图上钉住整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。TUI 面板未受改动(自动化专用的 ACP 桥接刻意不做 todo 呈现);Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。冷加载重建正是靠这个字段由 host 兜底:history 尾页附带 `todos`——全量 log 上最新一次 `todo/write` 的投影,独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时即使最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,而尾页响应不带投影时复位为空。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index ff72d8e9ee..43ca03dc7a 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-24-web-gui-browser-e2e-lane.md: c4e34b3f44162c7021cb25681eea7e49ac78f672 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 466e1c0fc16aac21b87b68cfedaec4fb22a417e2 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +2026-07-24-web-gui-browser-e2e-lane.md: d9e0a9660ecd6aeb75e835e68f92c0a268423872 +2026-07-24-web-gui-browser-e2e-lane.zh.md: e8c7d1c4596f20d88bd08423549fb6a9f7b0654b diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index c4e34b3f44..d9e0a9660e 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -18,7 +18,7 @@ A plain shared-fixture module (the [testing-policy sanctioned shape](../../../.. `launchWebScaffold()` boots the real web composition from the shipped `apps/cli/cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. -Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelContext` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. +Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelInfo` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. `seedSession()` seeds cold sessions through the real persistence API — a throwaway `Context` mounting `SessionStore` + `SessionPersistenceJsonl` against the host's root, `create()` + `append()`, one `utimes` backdate for deterministic sidebar order (the `semantic-checkpoint.snapshot.ts` precedent) — never raw file writes, so the seeder knows nothing of bucket hashing, filename encoding, or compression, and the host's zstd default needs no boot knob. Seeds are validated at seed time (parseable, ending in `turn/end` — an open final turn would be mutated by resume's crash repair). diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 466e1c0fc1..e8c7d1c459 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -18,7 +18,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu `launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 -无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelContext` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。 +无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelInfo` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。 `seedSession()` 通过真实持久化 API 播种冷会话——一次性 `Context` 挂载 `SessionStore` + `SessionPersistenceJsonl` 指向 host 的根目录,`create()` + `append()`,一次 `utimes` 回拨保证侧栏顺序确定(`semantic-checkpoint.snapshot.ts` 先例)——绝不裸写文件,因此播种器对桶哈希、文件名编码、压缩一无所知,host 的 zstd 默认值也无需任何启动开关。种子在播种时即校验(可解析、以 `turn/end` 结尾——未闭合的最终轮次会被恢复(resume)的崩溃修复改写)。 diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index 6f4085da44..a727844b1b 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -213,9 +213,9 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a }).toMatchInlineSnapshot(` { "subCells": [ - "#53Subbash · {"command":"ls notes","description":"List notes"}+0.8s", - "#54Subread · {"path":"notes/demo.txt"}+0.8s", - "#55Subread · {"path":"notes/missing.txt"}+0.8s", + "#51Subbash · {"command":"ls notes","description":"List notes"}+0.8s", + "#52Subread · {"path":"notes/demo.txt"}+0.8s", + "#53Subread · {"path":"notes/missing.txt"}+0.8s", ], } `) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 34d0e5f123..89269d743f 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -64,7 +64,7 @@ const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a -// catch-all would leave resolveModelContext unroutable and compact-basic's +// catch-all would leave resolveModelInfo unroutable and compact-basic's // post-step pressure check would warn every step). The published // contextWindow keeps that pressure path provably inert for small fixtures. const REPLAY_PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }] diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts new file mode 100644 index 0000000000..3116bf4242 --- /dev/null +++ b/apps/web/tests/todo-display.snapshot.ts @@ -0,0 +1,189 @@ +// @vitest-environment jsdom +// Todo display snapshot over the BUILT client graph (the code-mode-fixture +// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// Opens the fixture history session and pins the todo_write turn's two +// surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary +// derived from the call args) and the TodoPanel plan strip riding the +// 'conversation.input.dock' slot (fed by ConversationSnapshot.todos, seeded +// by the tail history page), including the collapse interaction. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } + __ModuleLoader__?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +beforeEach(() => { + localStorage.clear() + document.title = 'DeepSeek Harness' + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + delete (globalThis as Record).__fxTiming + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +/** Boot the complete built client graph against the populated fixture branch. */ +function boot(): void { + history.replaceState(null, '', '/?fixture') + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + act(() => { + const entry = new AppWebEntry(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) +} + +/** Collapse decorative whitespace while preserving the text a user sees. */ +function visibleText(element: Element): string { + return (element.textContent ?? '').replace(/\s+/g, ' ').trim() +} + +/** Open the fixture history session (the alpha log carrying the todo_write turn) and wait for its tail. */ +async function openFixtureSession(): Promise { + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + // Anchor on the expandable Workspace group row: the title and the blank + // session row can both read "fixture", and the session-count meta shifts + // when a blank session joins the group. + const group = (await within(tree).findAllByText('fixture')) + .map(el => el.closest('[role="treeitem"]')) + .find(el => el?.getAttribute('aria-expanded') !== null) + if (group === null || group === undefined) throw new Error('fixture Workspace group missing') + if (group.getAttribute('aria-expanded') === 'false') { + fireEvent.click(within(group).getByText('fixture')) + await waitFor(() => { + expect(group.getAttribute('aria-expanded')).toBe('true') + }) + } + const session = await within(tree).findByText('Fixture 历史会话') + fireEvent.click(session) + await waitFor(() => { + expect(document.querySelector('[data-sample="todo-row"]')).not.toBeNull() + }, { timeout: 10_000 }) +} + +it('renders the todo_write turn: dedicated tool row + the dock plan strip', async () => { + boot() + await openFixtureSession() + + const row = document.querySelector('[data-sample="todo-row"]') + if (row === null) throw new Error('todo row missing') + const panel = document.querySelector('[data-testid="todo-panel"]') + if (panel === null) throw new Error('todo panel missing from the input dock') + + expect({ + row: visibleText(row), + rowState: row.getAttribute('data-state'), + panelHeader: visibleText(panel.querySelector('button') ?? panel), + panelItems: [...panel.querySelectorAll('li')].map(item => ({ + status: item.getAttribute('data-status'), + text: visibleText(item), + })), + }).toMatchInlineSnapshot(` + { + "panelHeader": "Plan1/3", + "panelItems": [ + { + "status": "completed", + "text": "✓梳理需求", + }, + { + "status": "in_progress", + "text": "●实现 fixture 样本", + }, + { + "status": "pending", + "text": "○浏览器验收", + }, + ], + "row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本", + "rowState": "ok", + } + `) +}) + +it('collapses the plan strip to the in-progress hint and restores it', async () => { + boot() + await openFixtureSession() + + const panel = document.querySelector('[data-testid="todo-panel"]') + if (panel === null) throw new Error('todo panel missing from the input dock') + const header = panel.querySelector('button') + if (header === null) throw new Error('todo panel header missing') + + fireEvent.click(header) + expect({ + collapsedHeader: visibleText(header), + listGone: panel.querySelector('ul') === null, + }).toMatchInlineSnapshot(` + { + "collapsedHeader": "Plan1/3实现 fixture 样本", + "listGone": true, + } + `) + + fireEvent.click(header) + expect(panel.querySelectorAll('li')).toHaveLength(3) +}) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 1798b46908..e641d6ea2f 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: b426891c0483f42a64b597632cf1871aff79ca2d -architecture.zh.md: 13feefb6854e79ddee38602902d325a789fd7744 +architecture.md: 34e56dd955e6ac5ad8fad236bdf7ae9cfc810a83 +architecture.zh.md: ea105b869eba799d00dfaa134ed34d14c7505fc8 diff --git a/docs/architecture.md b/docs/architecture.md index b426891c04..34e56dd955 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -91,7 +91,7 @@ forever: agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen) + agent/request -> prepare reasoning/default under turn signal -> log request/header -> checkpoint -> llm/stream (frozen, registration-bound) on final adapter-path or terminal in-band failure: 'step/end' agent/request-error(original error, failure facts, immutable prior failures, signal) @@ -125,7 +125,7 @@ Pruning precedes summaries; overflow retries require durable progress. Bounded r Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retries open steps; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit nothing. -Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`. Cancellation and disposal beat recovery; the turn signal also cancels asynchronous model-capability preparation before any request header is committed, and undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). Session events are turn-enclosed; reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures use `agent/error`. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 13feefb685..ea105b869e 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -91,7 +91,7 @@ forever: agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen) + agent/request -> prepare reasoning/default under turn signal -> log request/header -> checkpoint -> llm/stream (frozen, registration-bound) on final adapter-path or terminal in-band failure: 'step/end' agent/request-error(original error, failure facts, immutable prior failures, signal) @@ -125,7 +125,7 @@ forever: 适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交任何内容。 -其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`。取消和资源释放均优先于恢复;轮次信号还会在提交任何请求头之前取消异步模型能力准备,尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 会话事件均位于轮次边界内;重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障使用 `agent/error`。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6a31c353fd..cce2bd0f89 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -565,18 +565,19 @@ Requires: `llm` /** * Plugin config, validated by the same-named schemastery schema. Every field * is optional in yml: credentials/endpoint fall back to the environment (a - * missing API key fails plugin load, not the first call), and omitted - * thinking fields send nothing on the wire, so the provider default applies. + * missing API key fails plugin load, not the first call), omitted thinking + * mode uses the provider default, and omitted reasoning effort resolves to + * `high`. */ export interface Config { /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ apiKey?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string - /** Thinking-mode default for every request (provider default: enabled). */ + /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' - /** Thinking effort (only meaningful with thinking enabled). */ - reasoningEffort?: 'high' | 'max' + /** Default thinking effort (default `high`); `off` disables thinking per request. */ + reasoningEffort?: 'off' | 'high' | 'max' /** Positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ @@ -598,7 +599,7 @@ export interface DeepSeekCatalogModel { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:34`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:35`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -622,7 +623,7 @@ export interface PiAiProviderProfile { /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record /** Provider-neutral pi-ai reasoning level. */ - reasoning?: ThinkingLevel + reasoning?: ModelThinkingLevel /** Token budgets used by reasoning providers that support them. */ thinkingBudgets?: ThinkingBudgets /** Prompt-cache retention preference. */ @@ -638,7 +639,7 @@ export interface PiAiProviderProfile { } ``` -Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `ThinkingLevel` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) +Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) Source: [`packages/llm/llm-pi-ai/src/config.ts:48`](../packages/llm/llm-pi-ai/src/config.ts) @@ -688,7 +689,7 @@ export interface ReplayModelConfig { } ``` -Source: [`packages/support/llm-replay/src/index.ts:590`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:598`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -1781,7 +1782,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:273`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/cookbook/adding-an-llm-adapter.i18n.yaml b/docs/cookbook/adding-an-llm-adapter.i18n.yaml index 497ae08c32..ebd3cf07a8 100644 --- a/docs/cookbook/adding-an-llm-adapter.i18n.yaml +++ b/docs/cookbook/adding-an-llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -adding-an-llm-adapter.md: f20442b8c2ce823452a3ea13409f202185d12d04 -adding-an-llm-adapter.zh.md: 2864dd1e18742c7449e24f22504a5a38976ab450 +adding-an-llm-adapter.md: 659fdab30e753c9a7acf9e06f85ebfc83480ec50 +adding-an-llm-adapter.zh.md: 5e2a84c82f9a8025c951b1bb2b44ba8b8950f5e3 diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index f20442b8c2..659fdab30e 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -32,7 +32,7 @@ Registration is effect-based (HMR-safe); one adapter per provider route — dupl - A `GenerateOptions` field your provider cannot honor (e.g. a `stop` list on a provider without stop sequences): throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping it. - If the provider requires response ids, signatures, or other native metadata on follow-up calls, emit the minimal lossless-JSON projection as `finish.replayState`. Validate it when rebuilding history. `LlmService` passes it only when the historical provider route and target provider route are currently owned by the exact same adapter instance; your adapter decides whether same-model, cross-model, or cross-provider restoration is legal. Never infer native replay from provider/model names alone when state is absent. -Provider-specific request knobs (thinking modes, effort levels) belong in the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays provider-neutral. +Provider-specific thinking-mode toggles remain in the adapter's Config. Exact model metadata uses one provider-neutral capability seam: implement `resolveModel()` with provider/model identity and optional `context` and `reasoning` fields, declare a configured `defaultEffort` only when one exists, and honor the resolver's optional `AbortSignal`. Reasoning efforts are ordered opaque ids mapped to provider requests by the adapter. Preserve the adapter's authoritative selectable list, including an adapter-defined `off` when supported, without exposing final wire spellings or clamping unsupported values; an id need not equal its wire representation. ## Structure that worked diff --git a/docs/cookbook/adding-an-llm-adapter.zh.md b/docs/cookbook/adding-an-llm-adapter.zh.md index 2864dd1e18..5e2a84c82f 100644 --- a/docs/cookbook/adding-an-llm-adapter.zh.md +++ b/docs/cookbook/adding-an-llm-adapter.zh.md @@ -32,7 +32,7 @@ export function apply(ctx: Context, config: Config) { - 如果 `GenerateOptions` 中某个字段你的提供方无法支持(例如提供方不支持 stop sequences 时收到 `stop` 列表):抛出 `LlmError(..., 'UNSUPPORTED')`,而非静默丢弃。 - 如果提供方在后续调用中需要响应 ID、签名或其他原生元数据,请将其最小无损 JSON 投影作为 `finish.replayState` 发出。重建历史时验证该状态。只有历史提供方路由和目标提供方路由当前由完全相同的适配器实例拥有时,`LlmService` 才会传递该状态;由适配器决定同模型、跨模型或跨提供方恢复是否合法。状态缺失时,切勿仅根据提供方/模型名称推断原生回放。 -提供方特有的请求旋钮(thinking 模式、effort 级别)放在**适配器**的 Config 中,而非 `GenerateOptions` 中——核心词汇保持提供方无关。 +提供方特有的 thinking 模式开关仍放在适配器的 Config 中。确切模型元数据使用一处提供方无关的能力 seam:实现 `resolveModel()`,返回提供方/模型身份以及可选的 `context` 和 `reasoning` 字段;仅当存在配置指定的默认值时才声明 `defaultEffort`;响应传给解析器的可选 `AbortSignal`。推理强度是由适配器映射到提供方请求的有序不透明 ID。请保留适配器给出的权威可选列表,包括适配器在支持时定义的 `off`;不得暴露最终协议值的具体拼写,也不得自动调整不支持的值。ID 无需与其协议表示相同。 ## 经验证有效的结构 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ccae4c9f9b..ef2f196dbf 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -616,7 +616,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:52`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:53`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2c72632c2f..6785e5f983 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -730,33 +730,57 @@ listProviders(): LlmProviderInfo[] async listModels(provider: string): Promise /** - * Resolve context capacity from the adapter that owns one exact route. - * This query is independent of the advisory model catalog: an unlisted model - * may return metadata, while `undefined` never rejects later routing. + * Resolve and validate all metadata from the adapter that owns one exact + * route. The result is detached from adapter-owned objects; catalog + * membership remains advisory and does not control request routing. * @param provider - registered provider route to inspect. * @param model - exact model id passed to the adapter. - * @returns detached context metadata, or `undefined` when the adapter has none. + * @param signal - optional cancellation for adapter-owned asynchronous lookup. + * @returns exact model identity plus available context and reasoning metadata. */ -async resolveModelContext( provider: string, model: string, ): Promise +async resolveModelInfo( provider: string, model: string, signal?: AbortSignal, ): Promise + +/** + * Validate a conversation call config against its exact model capability and + * materialize an adapter-configured default. Unsupported explicit efforts + * reject before provider I/O; no clamping or aliasing is performed. This + * standalone query does not bind a later dispatch; use {@link prepareCall} + * when logging and streaming must share one adapter registration. + * @param config - provider/model route and optional request controls. + * @param signal - optional cancellation for adapter-owned capability lookup. + * @returns a detached config only when a default must be materialized. + */ +async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise + +/** + * Resolve one call under its current adapter registration. The returned + * one-shot handle keeps that registration across header logging and dispatch, + * so HMR cannot combine one adapter's capability result with another adapter. + * @param config - provider/model route and optional request controls. + * @param signal - optional cancellation for adapter-owned capability lookup. + * @returns a prepared config and its registration-bound stream entry point. + */ +async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise /** * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for * `options.provider`. Replay state is retained only when the same adapter * instance owns its historical provider and the target provider. Final - * adapter selection, dispatch, and iteration failures retain their original - * Error identity and are tagged in a call-local scope for narrow agent-loop - * request recovery; middleware and nested-call failures remain untagged for - * the outer call. + * adapter selection remains fixed through asynchronous exact-model resolution + * and dispatch. Selection, dispatch, and iteration failures retain their + * original Error identity and are tagged in a call-local scope for narrow + * agent-loop request recovery; middleware and nested-call failures remain + * untagged for the outer call. * @param options - the full request; `options.provider` selects the adapter. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ stream(options: GenerateOptions): AsyncIterable ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:159`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:177`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -1342,7 +1366,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:606`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:611`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1878,7 +1902,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:150`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:153`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 47310dd09b..9b17460460 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -core.md: 781267cccdb5bbda33e5be6a9e807fdbe47dbc83 -core.zh.md: d0f67983b98b0cf679a8e599a5f8ab3c64490dd0 +core.md: 86aea325f5401d728a9b4aa147d78db9163a32c1 +core.zh.md: d09fb48419c14959eefb5a1df1c593b36c188cf5 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 781267cccd..86aea325f5 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -199,7 +199,7 @@ interface LlmModelInfo { } ``` -Correctness-sensitive model capacity is queried separately from the advisory catalog and is owned by the adapter serving the exact route. +Correctness-sensitive metadata is resolved separately from the advisory catalog and is owned by the adapter serving the exact route. Context capacity and reasoning choices share one exact-model result so consumers do not repeat authoritative model resolution. ```ts type-equiv /** Provider-owned context capacity for one exact provider/model route. */ @@ -209,12 +209,56 @@ interface LlmModelContext { } ``` +Reasoning effort is another exact-route capability. The core brands identifiers but does not enumerate their values; each adapter owns the ordered set, display names, and optional deployment default. + +```ts type-equiv +/** Adapter-owned identifier for one model's selectable reasoning effort. */ +type ReasoningEffortId = Branded<'ReasoningEffortId'> +``` + +```ts type-equiv +/** Display metadata for one adapter-owned reasoning effort. */ +interface LlmReasoningEffortInfo { + /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */ + id: ReasoningEffortId + /** Human-readable effort name for selectors and diagnostics. */ + name: string + /** Optional user-facing distinction from otherwise similar efforts. */ + description?: string +} +``` + +```ts type-equiv +/** Selectable reasoning efforts for one exact provider/model route. */ +interface LlmModelReasoningInfo { + /** Supported efforts in adapter-preferred display order. */ + efforts: readonly LlmReasoningEffortInfo[] + /** + * Adapter-configured default materialized into requests when callers omit + * an effort. Absence preserves the provider's own default. + */ + defaultEffort?: ReasoningEffortId +} +``` + +```ts type-equiv +/** Exact-route model metadata resolved by its owning adapter. */ +interface LlmResolvedModelInfo extends LlmModelInfo { + /** Provider-owned context capacity when known. */ + context?: LlmModelContext + /** Adapter-owned selectable reasoning levels when exposed. */ + reasoning?: LlmModelReasoningInfo +} +``` + ```ts type-equiv /** A single model request, fully assembled. */ interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string model: string + /** Adapter-owned reasoning effort selected for this exact model. */ + reasoningEffort?: ReasoningEffortId /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as @@ -291,21 +335,23 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset), and session prefix through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, or sampling. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. +`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. After the waterfall, the loop prepares the exact model capability under the turn signal, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. The prepared call keeps one adapter registration through dispatch. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. -FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. +FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). ```ts type-equiv /** - * Provider + model + sampling scalars of one conversation's requests. Every field maps - * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests - * from the logged header rather than accepting these per call. + * Provider, model, reasoning effort, and sampling scalars of one conversation's + * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field; + * the loop builds requests from the logged header rather than accepting these + * per call. */ interface LlmCallConfig { provider: string model: string + reasoningEffort?: ReasoningEffortId temperature?: number maxTokens?: number stop?: string[] diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index d0f67983b9..d09fb48419 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -205,7 +205,7 @@ interface LlmModelInfo { } ``` -对正确性敏感的模型容量与参考目录分开查询,并归服务该确切路由的适配器所有。 +对正确性敏感的元数据与参考目录分开解析,并归服务该确切路由的适配器所有。上下文容量和推理选项共用同一个确切模型结果,消费方因而无需重复执行权威模型解析。 ```ts type-equiv /** Provider-owned context capacity for one exact provider/model route. */ @@ -215,12 +215,56 @@ interface LlmModelContext { } ``` +推理强度是另一项针对确切路由的能力。核心为标识符添加品牌类型,但不枚举其值;有序集合、展示名称和可选的部署默认值均由各适配器持有。 + +```ts type-equiv +/** Adapter-owned identifier for one model's selectable reasoning effort. */ +type ReasoningEffortId = Branded<'ReasoningEffortId'> +``` + +```ts type-equiv +/** Display metadata for one adapter-owned reasoning effort. */ +interface LlmReasoningEffortInfo { + /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */ + id: ReasoningEffortId + /** Human-readable effort name for selectors and diagnostics. */ + name: string + /** Optional user-facing distinction from otherwise similar efforts. */ + description?: string +} +``` + +```ts type-equiv +/** Selectable reasoning efforts for one exact provider/model route. */ +interface LlmModelReasoningInfo { + /** Supported efforts in adapter-preferred display order. */ + efforts: readonly LlmReasoningEffortInfo[] + /** + * Adapter-configured default materialized into requests when callers omit + * an effort. Absence preserves the provider's own default. + */ + defaultEffort?: ReasoningEffortId +} +``` + +```ts type-equiv +/** Exact-route model metadata resolved by its owning adapter. */ +interface LlmResolvedModelInfo extends LlmModelInfo { + /** Provider-owned context capacity when known. */ + context?: LlmModelContext + /** Adapter-owned selectable reasoning levels when exposed. */ + reasoning?: LlmModelReasoningInfo +} +``` + ```ts type-equiv /** A single model request, fully assembled. */ interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string model: string + /** Adapter-owned reasoning effort selected for this exact model. */ + reasoningEffort?: ReasoningEffortId /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as @@ -297,21 +341,23 @@ interface ToolSchema { 循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词、权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)以及会话前缀。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 -`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 +`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 结束后,循环会在轮次信号控制下完成确切模型的能力准备,拒绝显式指定但不受支持的推理强度 ID(不自动调整),填入适配器配置的默认值,并记录最终生效值。准备完成的调用直至分派完成始终持有同一项适配器注册。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 -FIXME(call-config-shape):重新审视此类型的精确定义——出于缓存目的,哪些字段确实属于 epoch 层级(`model` 肯定属于;采样标量目前出于谨慎放在这里),以及适配器需要时,提供方特有的额外项(推理选项、额外 body 参数)应归属何处。 +FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 ```ts type-equiv /** - * Provider + model + sampling scalars of one conversation's requests. Every field maps - * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests - * from the logged header rather than accepting these per call. + * Provider, model, reasoning effort, and sampling scalars of one conversation's + * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field; + * the loop builds requests from the logged header rather than accepting these + * per call. */ interface LlmCallConfig { provider: string model: string + reasoningEffort?: ReasoningEffortId temperature?: number maxTokens?: number stop?: string[] diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 5de51d2794..df55d0f641 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -llm-streaming.md: fb97e74a9ec01e62ba940295112fb157cc73bd1d -llm-streaming.zh.md: fda59a64aeef1c037372d69dbc15ee0de48de222 +llm-streaming.md: ffe520eca12af0fa2d483b9f231c943107d74649 +llm-streaming.zh.md: d4d41040ceaa7e2b2ca08c47ae83fefbaba3f7d4 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index fb97e74a9e..ffe520eca1 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -157,7 +157,23 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). + +```ts type-equiv +/** One model call whose config and adapter registration were resolved together. */ +interface PreparedLlmCall { + /** Detached, deep-frozen config with any adapter-owned default materialized. */ + readonly config: LlmCallConfig + /** + * Dispatch this call once through the registration captured during + * preparation. The request's call-config fields must match {@link config}; + * reuse or mismatch fails with `INVALID_PREPARED_CALL`. + * @param options - fully assembled request carrying the prepared config. + * @returns the chunk stream, including the `llm/stream` waterfall. + */ + stream(options: GenerateOptions): AsyncIterable +} +``` ```ts public-api /** @@ -182,16 +198,19 @@ declare abstract class LlmAdapter { */ listModels(_provider: string): Promise; /** - * Resolve context capacity for one model accepted by this adapter. Absence - * means the adapter does not know the capacity, not that routing is invalid. - * @param _provider - one provider route owned by this adapter. - * @param _model - exact model id passed to {@link GenerateOptions.model}. - * @returns provider-owned context metadata, or `undefined` when unavailable. + * Resolve all metadata available for one exact model. This query is + * independent of the advisory catalog and does not validate request routing. + * @param provider - one provider route owned by this adapter. + * @param model - exact model id passed to {@link GenerateOptions.model}. + * @param _signal - cancellation for this exact-model lookup; asynchronous + * implementations must settle promptly after it aborts. + * @returns provider/model identity plus any context and reasoning metadata. */ - resolveModelContext( - _provider: string, - _model: string, - ): Promise; + resolveModel( + provider: string, + model: string, + _signal?: AbortSignal, + ): Promise; /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index fda59a64ae..d4d41040ce 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -157,7 +157,23 @@ declare class BlockAssembler { ## seam -`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerInfo()` 与异步 `listModels()` 方法为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单独的 `resolveModelContext()` 查询会暴露确切路由上对正确性敏感的容量信息,但不会让目录成员关系具有权威性;缺失表示元数据未知,而不是路由无效。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 +`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerInfo()` 与异步 `listModels()` 方法为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值,因此直接调用也无法绕过对不支持推理强度的拒绝;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 + +```ts type-equiv +/** One model call whose config and adapter registration were resolved together. */ +interface PreparedLlmCall { + /** Detached, deep-frozen config with any adapter-owned default materialized. */ + readonly config: LlmCallConfig + /** + * Dispatch this call once through the registration captured during + * preparation. The request's call-config fields must match {@link config}; + * reuse or mismatch fails with `INVALID_PREPARED_CALL`. + * @param options - fully assembled request carrying the prepared config. + * @returns the chunk stream, including the `llm/stream` waterfall. + */ + stream(options: GenerateOptions): AsyncIterable +} +``` ```ts public-api /** @@ -182,16 +198,19 @@ declare abstract class LlmAdapter { */ listModels(_provider: string): Promise; /** - * Resolve context capacity for one model accepted by this adapter. Absence - * means the adapter does not know the capacity, not that routing is invalid. - * @param _provider - one provider route owned by this adapter. - * @param _model - exact model id passed to {@link GenerateOptions.model}. - * @returns provider-owned context metadata, or `undefined` when unavailable. + * Resolve all metadata available for one exact model. This query is + * independent of the advisory catalog and does not validate request routing. + * @param provider - one provider route owned by this adapter. + * @param model - exact model id passed to {@link GenerateOptions.model}. + * @param _signal - cancellation for this exact-model lookup; asynchronous + * implementations must settle promptly after it aborts. + * @returns provider/model identity plus any context and reasoning metadata. */ - resolveModelContext( - _provider: string, - _model: string, - ): Promise; + resolveModel( + provider: string, + model: string, + _signal?: AbortSignal, + ): Promise; /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 38ca48f9a0..1797724b9d 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -session.md: 2cbbac8042d04522fea0b1ed7a66c503e4b63f4e -session.zh.md: e932c8f99f684f1b8985b006ab4ddb145db966bd +# pnpm run verify-translation-pairing --write docs/core-data-structures/session.md +session.md: 59bcf027c3ec728c5583478c7d17bd6eef2dc2fa +session.zh.md: 01b6f080b5fcf7b83ca46b99a8d80113a6ebe8bf diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 2cbbac8042..59bcf027c3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -175,7 +175,7 @@ The request envelope — the `EpochHeader` (call config + rendered system prompt * canonical empty optional fields are absent. */ interface EpochHeader { - /** The conversation's call configuration (provider, model, and sampling scalars). */ + /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig /** Rendered system prompt text; absent for a system-less request. */ system?: string diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index e932c8f99f..01b6f080b5 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -177,7 +177,7 @@ interface TodoItem { * canonical empty optional fields are absent. */ interface EpochHeader { - /** The conversation's call configuration (provider, model, and sampling scalars). */ + /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig /** Rendered system prompt text; absent for a system-less request. */ system?: string diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ced431bf51..d042a9e17a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -33,7 +33,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:53`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 07956b019b..311791e594 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1137,7 +1137,7 @@ Record and update a structured task list for the current work. Send the ENTIRE l "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 8735e8d5a6..402e3537a7 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -llm-adapter.md: 3e83289b8072ef231f83c0fa3cfe3260547b42fa -llm-adapter.zh.md: 92fcf9b22f4bb356ada4c46f9a03ef0cc2d159da +llm-adapter.md: 3bf005647d3e7908f1d1553374266c1d6c12c2e6 +llm-adapter.zh.md: b967d112ed6879b11486ab7aedf64653089d93de diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 3e83289b80..3bf005647d 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -110,7 +110,9 @@ async function* exampleChunks(): AsyncIterable { ## GenerateOptions -`stream()` receives the exported `GenerateOptions` type. It includes the model, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it. +`stream()` receives the exported `GenerateOptions` type. It includes the model, adapter-owned reasoning-effort id, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it. + +Override `resolveModel(provider, model, signal?)` to return exact provider/model identity plus optional `context` and `reasoning` metadata in one lookup. Reasoning metadata contains ordered opaque ids and display names plus an optional configured default; preserve the adapter's authoritative selectable list, including `off` when its upstream capability API returns it, instead of promoting those values into a core enum. Honor the optional signal for asynchronous lookup so cancellation and disposal reach quiescence. The service validates the aggregate and rejects unsupported explicit efforts before `stream()`; omitting `reasoning` means that model has no selectable reasoning-effort capability. ## Register an adapter diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index 92fcf9b22f..b967d112ed 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -110,7 +110,9 @@ async function* exampleChunks(): AsyncIterable { ## GenerateOptions -`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。 +`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、由适配器持有的推理强度 ID、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。 + +请覆写 `resolveModel(provider, model, signal?)`,在一次查询中返回确切的提供方/模型身份以及可选的 `context` 和 `reasoning` 元数据。推理元数据包含有序的不透明 ID、展示名称,以及可选的配置默认值;请保留适配器给出的权威可选列表,包括其上游能力 API 返回的 `off`,而不要将这些值提升为核心枚举。异步查询必须响应这个可选信号,让取消和资源释放都能达到完全停稳。服务会校验聚合结果,并在调用 `stream()` 前拒绝显式指定但不受支持的推理强度;省略 `reasoning` 表示该模型没有可选的推理强度能力。 ## 注册适配器 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 0e42b0c6ab..3a37f3da6a 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -162,7 +162,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 285031a1de..1abccfd566 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -366,7 +366,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 055ad93065..0cee2a6517 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -145,7 +145,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index fa3eba25d8..b61d7bf623 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -309,7 +309,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 055ad93065..0cee2a6517 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -145,7 +145,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index 055ad93065..0cee2a6517 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -145,7 +145,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 487f0517b1..e51933cdf3 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json index d4973bfea4..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 5d27e93da3..9b5925605c 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -325,7 +325,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index e9f7a2ea63..8e093db8bd 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -417,7 +417,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index dde0ba0d7a..beb93c6b53 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -492,7 +492,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index d4973bfea4..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index d4973bfea4..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index d4973bfea4..01ac777a42 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -288,7 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts index 5238e67374..0f0b02c1d9 100644 --- a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -1,8 +1,33 @@ import type { Context } from 'cordis' -import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { + CallId, + LlmAdapter, + ReasoningEffortId, + type GenerateOptions, + type LlmResolvedModelInfo, + type StreamChunk, +} from '@deepseek-ai/dsh-llm' + +const HIGH = ReasoningEffortId('high') +const OFF = ReasoningEffortId('off') /** Keyless headless-agent adapter: one real bash call followed by a final answer. */ class CliMockAdapter extends LlmAdapter { + override async resolveModel(provider: string, model: string): Promise { + return { + provider, + id: model, + name: model, + reasoning: { + efforts: [ + { id: OFF, name: 'Off' }, + { id: HIGH, name: 'High' }, + ], + defaultEffort: HIGH, + }, + } + } + async * stream(options: GenerateOptions): AsyncIterable { const toolResult = options.messages.at(-1)?.content.find(block => block.type === 'tool-result') if (toolResult === undefined) { @@ -34,4 +59,8 @@ export const inject = ['llm'] /** Register the keyless `cli-mock` adapter. */ export function apply(ctx: Context): void { ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) + ctx.on('agent/request', async (_agent, _turn, step, _config, _signal, next) => { + const config = await next() + return step === 2 ? { ...config, reasoningEffort: OFF } : config + }) } diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 6852fed20b..f474cb5460 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -28,6 +28,7 @@ const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' interface JsonObject { @@ -124,6 +125,46 @@ async function persistedLogs(cwd: string): Promise { } describe('headless stream-json snapshots', () => { + it('logs the model default and a dynamic next-step reasoning effort', async () => { + const result = await runLoaderSmoke({ + label: 'reasoning effort headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-reasoning-effort-', + binScript, + configPath: reasoningConfigPath, + binArgs: ['--config', reasoningConfigPath, '--output-format', 'stream-json', 'prove dynamic reasoning effort'], + tsconfigPath, + }) + + expect(result.stderr).toBe('') + const headers = parseJsonl(result.stdout) + .map(record => record.event) + .filter((event): event is JsonObject => ( + event !== null + && typeof event === 'object' + && !Array.isArray(event) + && 'type' in event + && event.type === 'request/header' + )) + .map((event) => { + const data = event.data as JsonObject + return (data.header as JsonObject).config + }) + expect(headers).toMatchInlineSnapshot(` + [ + { + "model": "cli-mock", + "provider": "cli-mock", + "reasoningEffort": "high", + }, + { + "model": "cli-mock", + "provider": "cli-mock", + "reasoningEffort": "off", + }, + ] + `) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('replays the advanced toolchain through the one-shot app', async () => { const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain') const fixtureFiles = [ diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 045b9bb736..6cae860e36 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 8193973bee..c00a4119c7 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/tui-agent/README.i18n.yaml b/examples/tui-agent/README.i18n.yaml index 631848bb77..3d29530411 100644 --- a/examples/tui-agent/README.i18n.yaml +++ b/examples/tui-agent/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: fdf3972f98f8ff9690b71469ae415dc18d339f10 -README.zh.md: 71f7ae949d034757a20adfae2cbe566011edc584 +# pnpm run verify-translation-pairing --write examples/tui-agent/README.md +README.md: ea8695d37ea247a38644392a4572c1ea9855fd44 +README.zh.md: b3f6dc18536b159379eac7433367ccf2cd8fcc53 diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index fdf3972f98..ea8695d37e 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -19,7 +19,7 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down to focus a model, Shift+Tab to cycle its advertised reasoning efforts, and Enter to select, or use `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. ### Resuming a prior session @@ -53,7 +53,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads | Entry | Demonstrates | |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it depends on the Loader's internal module access | -| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | +| `llm-deepseek` | the default native adapter | | `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | | `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the pi-tui channel + a pre-created `main` agent | | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md index 71f7ae949d..b3f6dc1853 100644 --- a/examples/tui-agent/README.zh.md +++ b/examples/tui-agent/README.zh.md @@ -19,7 +19,7 @@ pnpm run demo:tui `todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 -TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 和 Enter,或使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 +TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 ### 恢复早先的会话 @@ -53,7 +53,7 @@ pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay | 配置项 | 演示内容 | |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 | -| `llm-deepseek` | 通过配置提供真实 `LlmAdapter`(`!!js process.env.…` 密钥);将一行替换为 `@deepseek-ai/dsh-llm-pi-ai` 即可使用库后端对照实现 | +| `llm-deepseek` | 默认原生适配器 | | `bash` (`dsh-bash-local`) | 执行器实现:bash seam 的可替换一半。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 | | `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | 应用组合包:agent-spine 演示 + JSONL 持久化 + pi-tui 通道 + 预创建的 `main` agent | | `subagent`, `subagent-spawn`, `subagent-fork` | subagent 提供方注册表加两个进程内后端:新子 agent,以及用父 agent 已完成轮次前缀播种的子 agent | diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index c3bdbfd1b1..d3e35b6ce5 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -1,6 +1,11 @@ import type { Context } from 'cordis' -import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelInfo, + LlmResolvedModelInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}` @@ -35,8 +40,28 @@ class ScriptedTuiAdapter extends LlmAdapter { ]) } - override resolveModelContext(_provider: string, _model: string): Promise { - return Promise.resolve({ contextWindow: 128_000 }) + override resolveModel( + provider: string, + model: string, + ): Promise { + return Promise.resolve({ + provider, + id: model, + name: model === 'tui-scripted-model-pro' ? 'Scripted Pro' : 'Scripted Base', + context: { contextWindow: 128_000 }, + ...model !== 'tui-scripted-model-pro' + ? {} + : { + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }, + }) } override async * stream(options: GenerateOptions): AsyncIterable { @@ -47,8 +72,12 @@ class ScriptedTuiAdapter extends LlmAdapter { for (const chunk of textChunks(TITLE_TEXT)) yield chunk return } - if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) { - throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables') + if ( + options.model !== 'tui-scripted-model-pro' + || !options.system?.includes('tui-scripted-model-pro') + || options.reasoningEffort !== ReasoningEffortId('max') + ) { + throw new Error('the scripted TUI request did not apply the selected model and reasoning effort') } const lastMessage = options.messages.at(-1) const lastText = (lastMessage?.content ?? []) diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 5967366c12..1cbe6c478e 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -103,7 +103,7 @@ function smoke(overrides: Partial & { label: string }): Prom // other route (see fixtures/tui-scripted-llm.ts). const SELECT_PRO_MODEL = [ { waitFor: 'scripted TUI ready.', send: '/model\r' }, - { waitFor: 'Select model', send: '\x1b[B\r' }, + { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { @@ -156,6 +156,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { ], }) expect(output).toContain('I need one decision before I continue.') + expect(output).toContain('Reasoning effort: Max.') expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.') expect(output).toContain('Leaving plan mode (applies from the next step).') expect(output).toContain('Default mode confirmed.') diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index fcd04f2460..9aa43145ed 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -6,7 +6,7 @@ // approval/question requests exercise replay and composer takeover with stable rpcIds. import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, @@ -167,6 +167,22 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } + // Turn 65: todo_write sample — the TodoRow toolview in the flow plus the + // todo/write snapshot event feeding the TodoPanel plan strip. + const fixtureTodos = [ + { content: '梳理需求', status: 'completed' }, + { content: '实现 fixture 样本', status: 'in_progress' }, + { content: '浏览器验收', status: 'pending' }, + ] + const todoArgs = JSON.stringify({ todos: fixtureTodos }) + toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + // The real tool appends the snapshot mid-execution — between tool/call and + // tool/result — so the fixture reproduces that exact ordering (the last + // toolTurn events run ... tool/call, tool/result, step/end, turn/end). + const callIndex = events.length - 4 + const callTime = events[callIndex]?.time as number + events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } }) + events.forEach((e, i) => { e.seq = i }) return events as unknown as SessionEvent[] } @@ -281,6 +297,15 @@ function pageOf( return { events, hasMore: start > 0 } } +/** Current todo projection over the full log (host parallel: latest todo/write, last write wins). */ +function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined { + for (let i = log.length - 1; i >= 0; i--) { + const event = log[i] + if (event !== undefined && event.type === 'todo/write') return event.data.todos + } + return undefined +} + interface StreamConn { push(envelope: RpcRequest): void } @@ -619,12 +644,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const log = logs.get(request.payload.sessionId) ?? [] // Snapshot at request time, deliver after the transit delay (mirrors a real host under latency). const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50) + // Tail page carries the session-level todo projection (host parallel: full-log backscan). + const todos = request.payload.beforeSeq === undefined ? backscanTodos(log) : undefined const doomed = failNextHistory failNextHistory = false const delay = historyDelayMs if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) if (doomed) throw new Error('fixture: simulated history transport failure') - return ok(request, page) + return ok(request, { ...page, ...todos === undefined ? {} : { todos } }) }, prompt: (request) => { const { sessionId: id, mode, content } = request.payload diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 7200291229..ed5f88fb1d 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -71,6 +71,21 @@ describe('createFixtureApi', () => { expect(empty.result.value).toEqual({ events: [], hasMore: false }) }) + it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => { + const api = createFixtureApi() + const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 })) + if (!tail.result.ok) throw new Error('history failed') + const events = tail.result.value.events.map(e => e.event) + const todoAt = events.findIndex(e => e.type === 'todo/write') + expect(todoAt).toBeGreaterThan(0) + // Production ordering (the tool appends mid-execution): call → snapshot → result. + expect(events[todoAt - 1]?.type).toBe('tool/call') + expect(events[todoAt + 1]?.type).toBe('tool/result') + const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time) + expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0) + expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0) + }) + it('create adds a session and pushes host/session-added to open host streams', async () => { const api = createFixtureApi() const abort = new AbortController() diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index bb4a2d4d2b..d148c0dc5f 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: d2a10b3d97837ac859c52c206afab06913ea222e -README.zh.md: f23f8cb184242edbd6d19aeff5823f1efbee8eba +README.md: a434b2d5719de2f30a883ee6e0d26264b3c62f4e +README.zh.md: a79fa99578e6bce4f0ff8e7c1f7538df8a436778 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index d2a10b3d97..a434b2d571 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync. ## Workspace and Session lists diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index f23f8cb184..a79fa99578 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值(host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`,因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失。 ## Workspace 与 Session 列表 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b1300a192c..b4a418628f 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -30,7 +30,7 @@ export type { export type { AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall, - SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, + SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 49ae8634ec..e1b2c42962 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,11 +4,14 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' +export type { TodoItem } + /** Assistant content blocks sorted by what the UI cares about * (text body / collapsible reasoning / tool-call card head / other fallback). */ export type AssistantBlock = @@ -241,4 +244,7 @@ export interface ConversationSnapshot { */ blank: boolean lastAgentError: string | null + /** Current whole-list `todo/write` projection — the tail page's full-log value, then each live + * write (last write wins); empty = the log holds no plan. */ + todos: readonly TodoItem[] } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 75c55bc4bd..1dd0283429 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView, @@ -99,6 +99,9 @@ export class Session implements ObservableSnapshot { private queueCache: { rev: number; value: QueuedMessage[] } | null = null private frozenRev = 0 private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null + /** Current whole-list todo/write projection: each tail history response replaces it (an omitted + * field is the authoritative empty list) and every live write overwrites it. */ + private todos: readonly TodoItem[] = [] /** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends * copy-on-write the per-parent array so published snapshot references never mutate. */ private codeDispatches = new Map() @@ -479,13 +482,13 @@ export class Session implements ObservableSnapshot { this.openError = result.error return } - this.installWindow(result.value.events, result.value.hasMore) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos) // Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more. const tailSeq = this.windowTailSeq() if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) { result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result if (generation !== this.openGeneration) return - if (result.ok) this.installWindow(result.value.events, result.value.hasMore) + if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos) } this.openState = 'open' } catch (error) { @@ -503,11 +506,19 @@ export class Session implements ObservableSnapshot { * Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here * (doOpen flips it after install), so recursing would push every buffered event straight * back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */ - private installWindow(entries: HistoryEntry[], hasMore: boolean): void { + private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void { this.events = entries.map(e => e.event) this.views = entries.map(e => e.view) this.baseSeq = this.events[0]?.seq ?? 0 this.hasMore = hasMore + // Session-level projection from the tail page (full-log latest todo/write, + // independent of the window); an in-window write below re-derives the same + // value, and later live events keep overwriting it. Every caller here is a + // tail request (no beforeSeq), which the host answers with the projection + // or omits it only when the full log holds no todo/write — so an absent + // field is the authoritative empty list, not a missing carrier. Assigning + // it clears a plan the log never kept (a write lost to a host crash). + this.todos = todos ?? [] this.foldAdapter.reset(this.events, this.baseSeq, this.views) this.rebuildDerivedFromWindow() const buffered = this.liveBuffer @@ -558,7 +569,7 @@ export class Session implements ObservableSnapshot { const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES }) // Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself. if (result.ok && generation === this.openGeneration && this.openState === 'open') { - this.installWindow(result.value.events, result.value.hasMore) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos) } } catch (error) { console.error('[web-runtime] gap repair failed:', error) @@ -678,6 +689,10 @@ export class Session implements ObservableSnapshot { if (this.openCalls.delete(String(event.data.callId))) this.callsRev++ return } + case 'todo/write': { + this.todos = event.data.todos + return + } case 'turn/end': { // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it // into an interrupted terminal node (pulse stops, text survives) instead of deleting it. @@ -722,7 +737,10 @@ export class Session implements ObservableSnapshot { /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps * paging/stitching consistent, and makes the live freeze and the history replay converge on the - * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */ + * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). + * todos is deliberately NOT reset: it is session-level (seeded by the tail page's full-log + * projection, not derivable from an arbitrary window). The window always extends to the log + * tail, so an in-window todo/write can only overwrite it with the same latest value. */ private rebuildDerivedFromWindow(): void { this.partial = null this.openCalls.clear() @@ -792,6 +810,7 @@ export class Session implements ObservableSnapshot { promptError: this.promptError, blank: this.blankBit, lastAgentError: this.lastAgentError, + todos: this.todos, } } } diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 7fb150bb20..ada8550136 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -40,6 +40,8 @@ export const ev = { at(seq, { type: 'step/end', data: { turn, step } }), turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent => at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), + todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent => + at(seq, { type: 'todo/write', data: { todos } }), } /** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */ diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 9f6147cdd3..d906ff92a8 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -62,7 +62,7 @@ export class FakeApiClient implements IApiClient { onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index f5cf9a2138..6c223ef58b 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -22,9 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: return { api, session: new Session(SID, api) } } -function histResponse(events: SessionEvent[], hasMore = false) { +function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) { // history now returns HistoryEntry[] ({event, view?}); these tests are view-less. - return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) + return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } })) } describe('open', () => { @@ -153,6 +153,42 @@ describe('live event path', () => { }) }) + it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => { + const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }] + const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }] + const { session } = await opened() + expect(session.getSnapshot().todos).toEqual([]) + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.todoWrite(6, listA)) + expect(session.getSnapshot().todos).toEqual(listA) + feed(ev.todoWrite(7, listB)) + expect(session.getSnapshot().todos).toEqual(listB) + // Window replay converges on the same last snapshot (history contains both writes). + const replayed = makeSession() + replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)]) + await replayed.session.open() + expect(replayed.session.getSnapshot().todos).toEqual(listB) + }) + + it('seeds todos from the tail page projection when the last write precedes the window', async () => { + const list = [{ content: '窗口外的计划', status: 'in_progress' as const }] + // Cold open: the page window carries NO todo/write; the projection rides the response. + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list) + await session.open() + expect(session.getSnapshot().todos).toEqual(list) + // Paging an older window in must not clear the session-level projection. + api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false) + await session.loadOlder() + expect(session.getSnapshot().todos).toEqual(list) + // A later live write still overrides the seeded projection. + session.handleMuxEnvelope('r' as never, { + type: 'session/event', sessionId: SID, + event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]), + }) + expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }]) + }) + it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] @@ -166,6 +202,37 @@ describe('live event path', () => { const seqs = session.getSnapshot().nodes.map(n => n.seq) expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9 }) + + it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => { + const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 + expect(session.getSnapshot().todos).toEqual([]) + // The missed range contained a todo/write that the repulled page no longer + // covers; the response's session-level projection is the only carrier. + const current = [{ content: '断线期间写的', status: 'in_progress' as const }] + api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current) + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') }) + await vi.waitFor(() => { + expect(api.callsOf('session.history').length).toBe(2) + }) + await Promise.resolve() + expect(session.getSnapshot().todos).toEqual(current) + }) + + it('clears the plan when a tail response omits the projection (a write the log never kept)', async () => { + // Live write lands, then the host crashes before persisting it: the + // authoritative log holds no todo/write, so the resync tail response + // carries no projection — an omitted field on a tail request is the empty + // list, not a missing carrier, and the rolled-back plan must disappear. + const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) + session.handleMuxEnvelope('r' as never, { + type: 'session/event', sessionId: SID, + event: ev.todoWrite(6, [{ content: '丢失的计划', status: 'in_progress' as const }]), + }) + expect(session.getSnapshot().todos).toEqual([{ content: '丢失的计划', status: 'in_progress' }]) + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.resync() + expect(session.getSnapshot().todos).toEqual([]) + }) }) describe('paging', () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 8117d42ea4..2ffa02d313 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: b9ec555f158722ea1f41e01c4b3f7131d3fe3467 -README.zh.md: b1e3c1f4331148ebf1c58b4bcd4869270bb44311 +# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md +README.md: b242812411d513931ecd2767622f9e23fb0aaa34 +README.zh.md: 77f68e02d8d9161c413ae7d224121bc53547ba12 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b9ec555f15..b242812411 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). +Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant and the todo row), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService. @@ -12,6 +12,8 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a one-line header carrying the in-progress item. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. + Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index b1e3c1f433..77f68e02d8 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 +会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 无会话主视觉区会渲染来自 Session 列表投影的前端 Session Intent;没有真实 Workspace 时,还会包含其前端 Workspace Intent。它声明 `conversation.empty.workspace`,ui-workspace 会在此注册侧边栏所用的同一选择器。WorkspacesService 启动跨对象流程;每个 Workspace 或 Session 对象拥有自身的物化。Session 在发布期间保持身份,并保留任何仍需连接或交付的提示词;ConversationRoot 读取该 `pendingPrompt`,其来源是 `useSession`,再通过 scope 内的 ConversationService 编辑或重试。 @@ -12,6 +12,8 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带进行中条目的单行表头。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 + 逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c8d5be336d..208d75c5d4 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -13,6 +13,8 @@ import { InputHub } from './input/hub.ts' import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { todoToolview } from './toolviews/todo-row.tsx' +import { todoDockEntry } from './skeleton/TodoPanel.tsx' import { queueDockEntry } from './queue/QueueDock.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession } from './skeleton/ConversationSession.tsx' @@ -182,6 +184,12 @@ export function apply(ctx: Context): void { // The bash sample rides that exact seam, in third-party posture. ctx.plugin(bashToolviewSample) + // The todo_write row rides the same seam (a product registration, not a sample). + ctx.plugin(todoToolview) + + // The plan strip rides the input dock above the queue rows (same posture). + ctx.plugin(todoDockEntry) + // The read-only queue dock entry (T9 file territory) rides the same // registration seam into the input dock declared above. ctx.plugin(queueDockEntry) diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css new file mode 100644 index 0000000000..17c9c890a7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -0,0 +1,111 @@ +/* Plan strip pinned above the composer: bordered card on the composer card's + axis (776px column inside 32px side padding). Colors resolve through + --dsw-alias-* tokens only; the active row rides the business blue, done + rows fade to tertiary. */ + +.root { + flex: none; + overflow: hidden; + margin: 8px auto 0; + width: calc(100% - 64px); + max-width: 776px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-alias-bg-base); +} + +.header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + text-align: left; + cursor: pointer; +} + +.header:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.title { + font-size: 13px; + line-height: 16px; + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.progress { + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); +} + +.activeHint { + flex: 1; + min-width: 0; + overflow: hidden; + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + display: grid; + flex: none; + place-items: center; + margin-left: auto; + color: var(--dsw-alias-label-secondary); +} + +.list { + margin: 0; + padding: 0 12px 8px; + list-style: none; + max-height: 180px; + overflow-y: auto; +} + +.item { + display: flex; + align-items: baseline; + gap: 8px; + padding: 2px 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} + +.glyph { + flex: none; + width: 14px; + text-align: center; + color: var(--dsw-alias-label-tertiary); +} + +.item[data-status='completed'] .content { + color: var(--dsw-alias-label-tertiary); + text-decoration: line-through; +} + +.item[data-status='completed'] .glyph { + color: var(--dsw-alias-state-success-primary); +} + +.item[data-status='in_progress'] .content { + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.item[data-status='in_progress'] .glyph { + color: var(--dsw-alias-state-business-primary); +} + +.content { + min-width: 0; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx new file mode 100644 index 0000000000..283eeb3e5e --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -0,0 +1,87 @@ +// TodoPanel: persistent plan strip above the composer (the web counterpart +// of the TUI plan panel). Renders the latest todo/write whole-list snapshot — +// no data of its own, hidden while the list is empty. Mounted through the +// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does +// the selecting, so the panel takes the plain list and stays framework-free. + +import { useState } from 'react' +import type { Context } from 'cordis' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client' +import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './TodoPanel.module.css' + +export interface TodoPanelProps { + /** The session's current plan (empty renders nothing) — selected by the dock adapter. */ + todos: readonly TodoItem[] +} + +/** Status glyphs mirror the TUI plan panel (✓ done / ● active / ○ pending). */ +const STATUS_GLYPHS: Record = { + completed: '✓', in_progress: '●', pending: '○', +} + +export function TodoPanel({ todos }: TodoPanelProps) { + const [collapsed, setCollapsed] = useState(false) + if (todos.length === 0) return null + + const done = todos.filter(t => t.status === 'completed').length + const active = todos.find(t => t.status === 'in_progress') + + return ( +
+ + {!collapsed && ( +
    + {todos.map(item => ( +
  • + {STATUS_GLYPHS[item.status]} + {item.content} +
  • + ))} +
+ )} +
+ ) +} + +/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */ +export type TodoDockProps = PropsRuntime<'conversation.input.dock'> + +/** Dock adapter: selects the plan off the session snapshot and hands the strip a plain list. */ +export function TodoDock({ useSession }: TodoDockProps) { + const todos = useSession(s => s.todos) + return +} + +/** + * The plan strip as a plain registrant plugin (QueueDock posture). + * `inject: ['conversation']` is the ordering seam: the conversation service + * mounts after ui-conversation's slot registrations, so the + * 'conversation.input.dock' declaration is on the ledger by then. + */ +export const todoDockEntry = { + name: 'conversation-todo-dock', + inject: ['slots', 'conversation'], + /** + * Register the plan strip into the input dock (list entry, above the queue rows). + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock) + }, +} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css new file mode 100644 index 0000000000..ff4068d49c --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -0,0 +1,42 @@ +/* todo_write plan-update row: title + progress summary on one line. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + height: 24px; + min-width: 0; + cursor: pointer; + border-radius: 6px; + font-size: 13px; +} + +.row:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.badge { + flex: none; + color: var(--dsw-alias-state-business-primary); +} + +.title { + flex: none; + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-secondary); +} + +.err { + flex: none; + color: var(--dsw-alias-state-error-primary); + font-size: 11px; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx new file mode 100644 index 0000000000..353e7a5441 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -0,0 +1,93 @@ +// todo_write toolview: plan-flavored summary row replacing the generic +// "Tool call" card, registered into the keyed 'conversation.chat.toolview' +// hole like the bash sample (a product registration, not a sample). The row +// summarizes the written list (counts + active item) from the call args; the +// durable list itself renders in the TodoPanel above the composer, so the +// row stays one line. + +import type { KeyboardEvent } from 'react' +import type { Context } from 'cordis' +import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { toolRowModel } from '../contract/tool-call-model.ts' +import css from './todo-row.module.css' + +/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */ +interface TodoWriteItem { content?: unknown; status?: unknown } + +function isItem(value: unknown): value is TodoWriteItem { + return typeof value === 'object' && value !== null +} + +function summarize(argsRaw: string): string | null { + let parsed: unknown + try { + parsed = JSON.parse(argsRaw) + } catch { + // Mid-stream truncation or malformed model JSON: fall back to the generic summary. + return null + } + // Valid JSON with an invalid shape (null root, non-array todos, null items — + // a rejected tool/call retains such args verbatim): same generic fallback. + if (typeof parsed !== 'object' || parsed === null) return null + const todos = (parsed as { todos?: unknown }).todos + if (!Array.isArray(todos) || !todos.every(isItem)) return null + const done = todos.filter(t => t.status === 'completed').length + const active = todos.find(t => t.status === 'in_progress') + const head = `${done}/${todos.length} 已完成` + return typeof active?.content === 'string' && active.content !== '' + ? `${head} · ${active.content}` + : head +} + +/** One-line plan update row (click opens the raw args in details). Non-ok + * execution states keep the generic row's dot semantics — a cancelled call + * wrote no todo/write, so it must not read as a completed update. */ +export function TodoRow({ toolName, block, openDetails }: ToolRowProps) { + const model = toolRowModel(toolName, block) + const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' + const summary = summarize(argsRaw) ?? model.summary + // Button semantics, not a