From de96087923ce63b967fa6bd64ff4de598f1151d2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 17:44:18 +0800 Subject: [PATCH 1/9] feat(web): name context sources and mark steering in the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every logged non-user user/message collapsed into one identical 「上下文注入」 row, and mid-turn steering rendered in exactly the bubble a turn-opening prompt uses, so the transcript could not say what had been added or which message interrupted a running turn. TranscriptAdapter and the history fold now attach a provenance view to each context node, computed by contextProvenance() from the durable source alone: a role (inject, or recall for a cross-session snapshot) and a producer name read out of the log — instruction paths for workspace-instructions, session titles for session-reference, the plugin id for a plugin source, and the bare kind for any other. No client-side table of producer names, so a renamed or newly mounted producer stays identifiable without a client release and a foreign log projects like a live one. ContextInjectionRow titles itself from the role and shows the name beside it; MessageItem captions durable and pending steering bubbles. The caption reverses one clause of the no-interjection-chrome decision, which removed it because the composer could not steer; composer steering shipped afterwards without amending that note, so this change supplies the product decision its reintroduction clause required and corrects the stale facts left in it. Fixes #1291 --- ...b-context-source-and-steer-marks.i18n.yaml | 6 ++ ...8-04-web-context-source-and-steer-marks.md | 48 +++++++++ ...4-web-context-source-and-steer-marks.zh.md | 48 +++++++++ ...eer-entry-or-interjection-chrome.i18n.yaml | 4 +- ...i-no-steer-entry-or-interjection-chrome.md | 1 + ...o-steer-entry-or-interjection-chrome.zh.md | 1 + apps/web/tests/seeded-history.e2e.ts | 12 ++- .../snapshots/bash-abort-row/ui.expected.md | 4 +- .../snapshots/code-mode-round/ui.expected.md | 4 +- .../cordis-tool-round/ui.expected.md | 4 +- .../snapshots/fresh-round-trip/ui.expected.md | 4 +- .../lifecycle-chrome/reloaded.expected.md | 4 +- .../live-interactions/cancel.expected.md | 4 +- .../live-interactions/error-auth.expected.md | 4 +- .../live-interactions/loading.expected.md | 4 +- .../live-interactions/retry.expected.md | 4 +- .../plan-review/approved.expected.md | 4 +- .../question-composer/answered.expected.md | 4 +- .../queue-actions/collapsed.expected.md | 4 +- .../queue-actions/editing.expected.md | 4 +- .../queue-actions/layout.expected.md | 12 +-- .../queue-actions/preserved.expected.md | 4 +- .../snapshots/queue-actions/ui.expected.md | 4 +- .../seeded-history/command-row.expected.md | 4 +- .../snapshots/seeded-history/ui.expected.md | 4 +- .../snapshots/steering/mid-steer.expected.md | 6 +- .../snapshots/steering/settled.expected.md | 6 +- .../subagent-conversation/ui.expected.md | 4 +- .../snapshots/web-search-round/ui.expected.md | 4 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/src/client/index.ts | 1 + .../client/session-history/history-fold.ts | 2 + .../src/client/sessions/context-provenance.ts | 91 ++++++++++++++++++ .../src/client/sessions/conversation.ts | 3 + .../src/client/sessions/transcript-adapter.ts | 2 + .../runtime/tests/context-provenance.spec.ts | Bin 0 -> 3196 bytes .../runtime/tests/transcript-adapter.spec.ts | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../chat/ContextInjectionRow.module.css | 14 +++ .../src/client/chat/ContextInjectionRow.tsx | 16 ++- .../src/client/chat/MessageItem.module.css | 9 ++ .../src/client/chat/MessageItem.tsx | 14 ++- .../ui-conversation/src/client/locales.ts | 4 + .../tests/chat-branch-tails.spec.tsx | 52 +++++++++- .../ui-conversation/tests/chat-view.spec.tsx | 5 + .../tests/context-branches.spec.ts | 1 + 50 files changed, 380 insertions(+), 74 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md create mode 100644 packages/client/runtime/src/client/sessions/context-provenance.ts create mode 100644 packages/client/runtime/tests/context-provenance.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml new file mode 100644 index 0000000000..c7695cfd3f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.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-08-04-web-context-source-and-steer-marks.md +2026-08-04-web-context-source-and-steer-marks.md: 5d158039b742af58a91b0e9d194b98e89485d8d7 +2026-08-04-web-context-source-and-steer-marks.zh.md: 46f5285033671407c1d26794cfd4ba74c06a8ad9 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md new file mode 100644 index 0000000000..5d158039b7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -0,0 +1,48 @@ +# Agent Note: Web transcript marks context source, recall, and steering + +Status: implemented + +English | [中文](2026-08-04-web-context-source-and-steer-marks.zh.md) + +## Problem + +Everything a producer adds to the model-facing conversation reached the Web transcript as one of two anonymous shapes. Every logged non-user `user/message` — the skill catalog, the runtime snapshot, reconciled `AGENTS.md` instructions, a guard notice, a subagent report, a cross-session snapshot — collapsed into one identical `上下文注入` row, so a reader could not tell what had been added without expanding each row and reading raw JSON. Mid-turn steering was worse: it rendered in exactly the bubble a turn-opening prompt uses, leaving the transcript unable to say which message interrupted a running turn. + +The distinctions are already durable. `user/message.source` is the merge-extensible provenance every producer must supply, and steering has its own `steering/message` event; only the presentation discarded them. The terminal transcript this Web UI replaced did name each card's producer, so the Web surface was a regression for the same log. + +## Decision + +The transcript names all three roles a non-prompt message can play — injected context, recalled session, and steering. + +`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the expanded body, its 141px scrollport, and the truncation policy are unchanged from the [disclosure decision](2026-07-30-web-context-injection-disclosure.md). + +**The label is read out of the log, never from a client-side table of producer names.** `workspace-instructions` is named by the distinct instruction paths it reconciled, `session-reference` by the titles of the sessions it read, a plugin source by its logged plugin id, and any other source by its own `kind` — the documented default arm for a merge-extensible union. A source carrying no readable kind degrades to an unnamed injection. A new or renamed producer is therefore identifiable without a client release, no label can go stale against the code, and a resumed, forked, or foreign log projects exactly like a live session. + +`recall` covers `session-reference` because that is the one shipped source that lifts another session's material into this one. No Web leaf mounts `dsh-session-reference` today — it had only a terminal host — so the arm exists for log portability rather than for a bundled producer, and it is exercised by unit coverage rather than an assembled Web scenario. + +`MessageItem` captions durable and pending steering bubbles with `插话`. This reverses one clause of [no steer entry or interjection chrome](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners. + +## Alternatives considered + +**Localize producer names in the client.** A dictionary keyed by plugin id would read better than `@deepseek-ai/dsh-system-prompt`, but it drifts silently on every rename, needs a client change per new producer, and cannot name a producer from a foreign log at all. Provenance the log already carries is worth more than prose the client invents. + +**Register presentations per source kind.** The disclosure decision deferred a keyed context-view slot until source-owned presentations emerged. Naming a row is not a distinct presentation, and a registry keyed on mounted producers would fail exactly where it matters — a resumed log whose producer is no longer mounted still has to render. + +**Compute the role and label on the host.** The host would have to attach a view to each event copy, duplicating what the durable source already states and adding a wire field per context message. The projection derives it once per node instead, where the transcript's other derived facts live. + +**Give steering its own row instead of a captioned bubble.** Steering is a user message that arrived mid-turn; a separate row shape would break the right-aligned reading rhythm and duplicate the bubble's copy and branch actions for no new information. + +**Extend the trajectory table with the same names.** Out of scope: the table's context cell has its own text derivation, and the issue asks for the conversation surface. + +## Testing + +- `packages/client/runtime` unit coverage pins each provenance arm, the label fallbacks when a name field is missing, empty, or wrongly typed, and the unnamed degradation for a source with no readable kind. +- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, the roleless header, and the steering caption on both durable and pending bubbles. +- The keyless assembled-Web goldens carry the named header and the steering caption, so the assembled transcript — not only component tests — proves the marks. + +## Consequences + +- A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for. +- Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better name records better provenance. +- `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it. +- The `recall` arm has no producer in a shipped Web leaf until a host mounts `dsh-session-reference`; it is reachable only through logs written elsewhere. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md new file mode 100644 index 0000000000..46f5285033 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -0,0 +1,48 @@ +# Agent Note:Web transcript 标出上下文来源、召回与 steering + +Status: implemented + +[English](2026-08-04-web-context-source-and-steer-marks.md) | 中文 + +## Problem + +生产方向模型侧对话补充的一切内容,进入 Web transcript(文本记录)后只剩两种匿名形态。每一条已记录的非用户 `user/message`——skill 目录、运行时快照、经过对账的 `AGENTS.md` 指令、guard 提示、子 agent 汇报、跨会话快照——都塌缩成同一行 `上下文注入`,读者不逐行展开去读原始 JSON 就无从知道究竟注入了什么。steering(中途引导)的情况更糟:它渲染成与开轮提示完全相同的气泡,于是 transcript 无法说明哪一条消息打断了正在运行的轮次。 + +这些区分本来就是持久事实。`user/message.source` 是每个生产方都必须提供的可合并扩展来源,steering 另有自己的 `steering/message` 事件;把它们丢掉的只有呈现层。被这套 Web UI 取代的终端 transcript 本来会写出每张卡片的生产者,因此面对同一份日志,Web 侧是一次倒退。 + +## Decision + +transcript 为非提示消息可能承担的三种角色分别命名:注入上下文、召回会话、steering。 + +`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;展开内容区、141px 滚动视口与截断策略沿用[展开项决策](2026-07-30-web-context-injection-disclosure.md),未作改动。 + +**名称从日志中读出,绝不来自客户端维护的生产者名称表。** `workspace-instructions` 以它对账过的去重指令文件路径命名,`session-reference` 以它读取的会话标题命名,插件来源以其记录的插件 id 命名,其余来源则以自身的 `kind` 命名——这正是可合并扩展联合类型有文档记载的默认分支。没有可读 kind 的来源降级为无名注入。于是新增或重命名的生产者无需客户端发版即可辨识,任何名称都不会相对代码变味,恢复、fork 或来自外部的日志与实时会话的投影结果完全一致。 + +`recall` 覆盖 `session-reference`,因为它是当前唯一会把另一个会话的材料搬进本会话的已发布来源。今天没有任何 Web 叶子挂载 `dsh-session-reference`——它此前只有终端宿主——因此该分支的存在是为了日志可移植性,而不是为了某个已打包的生产方,其覆盖来自单元测试而非组装后的 Web 场景。 + +`MessageItem` 为持久与待处理的 steering 气泡加上 `插话` 标注。这推翻了[取消 steer 入口与插话装饰](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论——当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。 + +## Alternatives considered + +**在客户端本地化生产者名称。** 以插件 id 为键的字典读起来确实比 `@deepseek-ai/dsh-system-prompt` 好,但它会在每次重命名时悄悄失准,每新增一个生产者都要改客户端,而且对来自外部的日志根本无法命名。日志已经承载的来源,比客户端自己编出来的措辞更有价值。 + +**按来源 kind 注册呈现。** 展开项决策把键控的 context-view 槽位推迟到出现由来源自有的呈现需求为止。为一行命名并不构成独立呈现,而以「已挂载的生产者」为键的注册表恰恰会在最要紧的地方失效——生产者已不再挂载的恢复日志同样必须渲染出来。 + +**在 host 侧计算角色与名称。** 那需要为每份事件副本附加一个视图,重复陈述持久来源已经说明的事实,并为每条上下文消息增加一个 wire 字段。改由投影为每个节点计算一次,与 transcript 其他派生事实同处一地。 + +**给 steering 独立的行而非带标注的气泡。** steering 是一条在轮次中途抵达的用户消息;独立行形会打断右对齐的阅读节奏,并且要为零新增信息重复气泡上的复制与分支操作。 + +**把同一套名称扩展到 trajectory 表格。** 不在本次范围内:该表格的上下文单元格有自己的文本推导,而 issue 要求的是对话面。 + +## Testing + +- `packages/client/runtime` 单元覆盖钉住每个来源分支、名称字段缺失/为空/类型不符时的回退,以及来源没有可读 kind 时的无名降级。 +- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存、无名时的标题形态,以及持久与待处理气泡上的 steering 标注。 +- 无密钥的组装 Web 黄金基线携带带名称的标题栏与 steering 标注,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 + +## Consequences + +- 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。 +- 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill`、`@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好名称的生产者应当记录更好的来源。 +- `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。 +- 在某个宿主挂载 `dsh-session-reference` 之前,`recall` 分支在已发布的 Web 叶子中没有生产者,只能通过别处写入的日志抵达。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml index 2059841653..2518f0de2d 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.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 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 6f397668106a6c74f327fc799327752c54824d8c -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: f127bd57006747465a1ece87406f5f97086b6a34 +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 15b9b9d299161e9d7404b43cfabfdc11e87d8feb +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 8c25f2700c316e75d29143e85c4c966fcb744c85 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md index 6f39766810..15b9b9d299 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md @@ -28,6 +28,7 @@ Keep host and runtime steering intact. Remove only the Web UI entry and chrome: ## Consequences +- **Superseded in part.** Composer steering shipped afterwards, so the first Decision bullet and the first consequence below no longer describe master: `InputMachine` carries the `queue`/`steer` mode union again and Web users can steer. The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required and restored the `插话` caption on steering bubbles. Host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript still hold. - Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls. - Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections. - Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one. diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md index f127bd5700..8c25f2700c 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md @@ -28,6 +28,7 @@ Status: implemented ## 后果 +- **部分被取代。** composer steering 在此之后落地,因此 Decision 的第一条与下面的第一条后果都已不再描述 master:`InputMachine` 重新携带 `queue`/`steer` mode 联合类型,Web 用户可以 steer。随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策,并恢复了 steering 气泡上的 `插话` 标注。host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍,仍然成立。 - Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。 - Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。 - 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index ba0cb50e40..718aabbbce 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -236,7 +236,10 @@ describe('web e2e: seeded history renders through cold resume', () => { }], }, })) - await page.getByRole('button', { name: 'Context injection' }).waitFor({ timeout: 10_000 }) + // The header names the producer the durable source records, so the + // reconciled instruction file is readable without expanding the row. + await page.getByRole('button', { name: 'Context injection · AGENTS.md', exact: true }) + .waitFor({ timeout: 10_000 }) }, 60_000) it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { @@ -254,7 +257,7 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection')) - const disclosure = page.getByRole('button', { name: 'Context injection' }) + const disclosure = page.getByRole('button', { name: 'Context injection · AGENTS.md', exact: true }) expect(await disclosure.getAttribute('aria-expanded')).toBe('false') const collapsedIcon = disclosure.locator('svg').first() const collapsedIconBox = await collapsedIcon.boundingBox() @@ -365,9 +368,8 @@ describe('web e2e: seeded history renders through cold resume', () => { source: { kind: 'plugin', plugin: 'fixture' }, })) - const disclosures = page.getByRole('button', { name: 'Context injection' }) - await expect.poll(() => disclosures.count(), { timeout: 10_000 }).toBe(2) - const disclosure = disclosures.nth(1) + const disclosure = page.getByRole('button', { name: 'Context injection · fixture', exact: true }) + await disclosure.waitFor({ timeout: 10_000 }) await disclosure.click() await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index 8f09d36efd..f738eb0ff5 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - 'button "Failed Bash Error: command aborted" [expanded]': - img - text: "Failed Bash Error: command aborted" diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 42758237bb..cde7820e28 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - 'button "Think The user wants me to write a single `run_code` program that:"': - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 85ef8932b2..c3e97df0f9 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user wants me to:": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 159e77d5bc..959e9db772 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index f6f965337e..2d1aa1854f 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user wants me to reply with a single word. Let me comply.": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index cd06310db0..3b67a54c51 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 1a4aec678c..508958f617 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - status: - text: This turn failedAPI key is invalid - code: AUTH diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index c50b440f86..9ad29a3bcf 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 5f834e15f0..eba44b97c1 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - group: - status: Retried model request (1/2) · {{duration}} - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 2120c32408..18fbf294b5 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -11,10 +11,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."': - img - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index a54b7dcd75..3cfde08bd2 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index bd44e33ad0..7bbe5f5cb5 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - button "2 queued messages" diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 8df2ea2940..d31126510c 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - button "2 queued messages" [disabled] [expanded] diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 08aea1a327..665eb1ccca 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -8,18 +8,18 @@ - img - img - text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear" -- button "Context injection": +- button "Context injection · goal": - img - img - - text: Context injection -- button "Context injection": + - text: Context injection · goal +- button "Context injection · goal": - img - img - - text: Context injection -- button "Context injection": + - text: Context injection · goal +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - region "To-dos": diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 2ce0077a2e..866047f95d 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 7afdf3c8b4..6476183b5f 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - list: diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index d638821c45..2f0d9334f8 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -37,10 +37,10 @@ - button "Context compacted View compaction summary": - img - text: Context compacted View compaction summary -- button "Context injection": +- button "Context injection · AGENTS.md": - img - img - - text: Context injection + - text: Context injection · AGENTS.md - img - text: permission preset read-only - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 9e78cca0f8..565150c4f9 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -37,10 +37,10 @@ - button "Context compacted View compaction summary": - img - text: Context compacted View compaction summary -- button "Context injection": +- button "Context injection · AGENTS.md": - img - img - - text: Context injection + - text: Context injection · AGENTS.md - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 5b316f4efb..93ceadfa28 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -24,7 +24,7 @@ - img - text: Ask question waiting - status: Deep diving... -- text: "Interjection: include the word BANANA in your final reply." +- text: "Steer Interjection: include the word BANANA in your final reply." - button "Copy": - img - region "Ready to continue?": diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 00f1b33206..2066d12848 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -22,7 +22,7 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Steer Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img - button "Branch into a new conversation" [disabled]: diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 561277b8ee..8480bf9bbe 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -15,10 +15,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 45098769f7..567e3f83c3 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Search DeepSeek Harness snapshot search": - img - img diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index c3850804ff..44a1c9d0c4 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: 89e58f967f852bb0786a5b7d73fa8e924fa282e0 -README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a +README.md: 80fb7e02ff357694bef5237a86983a22bf222572 +README.zh.md: b242720fc1ed693a1f681c186adf5ef4f1cef42a diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 89e58f967f..80fb7e02ff 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 960e2fceed..b242720fc1 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 0a384fffa2..ac1e84793b 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -53,6 +53,7 @@ export type { export type { ConversationContext, ConversationContextOriginKind, } from './sessions/conversation-context.ts' +export type { ContextProvenanceView, ContextRole } from './sessions/context-provenance.ts' export type { ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, } from './sessions/request-inspection.ts' diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index c4bc6ed9b5..af915b1deb 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -11,6 +11,7 @@ import type { PartialAssistant, RunningToolCall, } from '../sessions/conversation.ts' import { toAssistantBlocks } from '../sessions/conversation.ts' +import { contextProvenance } from '../sessions/context-provenance.ts' import type { ConversationContext, ConversationContextOriginKind, } from '../sessions/conversation-context.ts' @@ -126,6 +127,7 @@ function materializeNode( return { kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, + provenance: contextProvenance(event.data.source), } } return { diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts new file mode 100644 index 0000000000..3237c1a22e --- /dev/null +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -0,0 +1,91 @@ +// Context provenance projection: the role and the human-facing producer name +// of one logged non-user `user/message`, read from its durable `source` alone. +// The client keeps no table of known plugin ids — a renamed or newly mounted +// producer must never need a client release to stay identifiable, and a resumed +// or foreign log must project the same way as a live one. + +/** + * Which model-facing role a logged non-user message plays. + * + * `recall` marks material lifted out of another session's log; `inject` marks + * every other producer-supplied context. Mid-turn steering is the third role + * the transcript distinguishes, but it has its own event and node kind + * (`steering/message` / `SteeringMessageNode`) and never reaches here. + */ +export type ContextRole = 'inject' | 'recall' + +/** Role and producer name presented for one logged non-user message. */ +export interface ContextProvenanceView { + /** The role this context plays in the model-facing conversation. */ + role: ContextRole + /** + * Producer name for the row header, taken from the durable source: the + * instruction paths, the referenced session titles, the plugin id, or the + * bare source kind for a producer this UI version does not know. Null only + * when the source carries no readable kind at all. + */ + label: string | null +} + +/** One durable source narrowed to the readable-record shape; null for anything else. */ +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null +} + +/** A record field read as a non-empty string, or null. */ +function readString(record: Record, key: string): string | null { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +/** Distinct non-empty `field` values of an array-valued source member, in first-seen order. */ +function collect(source: Record, member: string, field: string): string[] { + const list = source[member] + if (!Array.isArray(list)) return [] + const seen: string[] = [] + for (const entry of list) { + const record = asRecord(entry) + const value = record === null ? null : readString(record, field) + if (value !== null && !seen.includes(value)) seen.push(value) + } + return seen +} + +/** A collected name list rendered as one label; null when the list is empty. */ +function joined(names: string[]): string | null { + return names.length > 0 ? names.join(', ') : null +} + +/** + * Project one durable message source onto its transcript role and producer name. + * + * The source arrives over the wire as opaque JSON (`MessageSource` is + * merge-extensible, so no client-side union can be exhaustive), and a durable + * log may predate or postdate this UI; every unreadable shape therefore + * degrades to `inject` with whatever name the record still carries. + * @param source - the logged `user/message` source, exactly as recorded. + * @returns the role and producer name to present for this context. + */ +export function contextProvenance(source: unknown): ContextProvenanceView { + const record = asRecord(source) + const kind = record === null ? null : readString(record, 'kind') + if (record === null || kind === null) return { role: 'inject', label: null } + switch (kind) { + // Cross-session snapshots are the one durable source that carries another + // session's material; its references name the sessions they were read from. + case 'session-reference': + return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind } + // Workspace instructions name the files they were reconciled from, which + // identifies the producer far better than the plugin id would. + case 'workspace-instructions': + return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind } + case 'plugin': + return { role: 'inject', label: readString(record, 'plugin') ?? kind } + // Documented default arm of the merge-extensible source map: an unknown + // producer still identifies itself by its own durable kind. + default: + return { role: 'inject', label: kind } + } +} diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index c4e195bc88..b4c7ebb71b 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -12,6 +12,7 @@ import type { InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' +import type { ContextProvenanceView } from './context-provenance.ts' export type { TodoItem } /** Request configuration recorded for one provider call. */ @@ -123,6 +124,8 @@ export interface ContextMessageNode { time: number content: readonly ContentBlock[] source: unknown + /** Role and producer name projected from `source` ({@link contextProvenance}). */ + provenance: ContextProvenanceView } /** Durable notice that a closed failed step is waiting for a model-request retry. */ diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 05ac3cb067..854c461755 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -22,6 +22,7 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' +import { contextProvenance } from './context-provenance.ts' /** * The compaction seam's checkpoint plugin, pinned to the seam's own declaration @@ -60,6 +61,7 @@ function materializeNode( return { kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, + provenance: contextProvenance(event.data.source), } } return { diff --git a/packages/client/runtime/tests/context-provenance.spec.ts b/packages/client/runtime/tests/context-provenance.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..94d1a12038340e835db2da08095a63ac2e9bc250 GIT binary patch literal 3196 zcmcguO>f&q5basNVs4ZTNkoDG1q7r(8@PopL6EekAcqyX61Q6JvO7!5f}sE2H~bJK zTXy86so(=m?tHy@^JdfQ>k(bkJMW^tk5mPJr=4<{mdC$!7L9jviiM`&ttRDi`HvJE$@!Gpm)JP|zx;W$(f!Ypz!8uekv$s?D^gvM9&_7~cX2+52S0fm7|W$Tr316YeK zK64L7_toSl_SBU%eiUZBqqTAQoRZcOX6*ysx>Xy7~{fOpkL(Cf%upVr`8>t;kdNZ?i(VmF78p z?v4@2q&jjZtOXIyd?gUge){!~oA-Zynw8R=O2q;%hgY=koj&7(>%}=Qc%6RLQ_z(K z)ZdcPmo_yJ;DXK{5<%$?@)G_G94HmK4@PIfGoyjcun;>{Va_8dmbNQv8*ymqFSO{V zN=8&B{Tut;xtRvN)Imd-4z0e^LB@7YulkN0&Pbn1j?ecuxIUn%BQy34IEivZ2m%ZdFWSI zbxf@#XagNl*_xtny3wh>R6&~pS|KfJ#a?!ZY#cyjp(sqXt65gYgL2c9ZUEPoR48e! zb=8PdQ>u`)QDMa`DVvQWUgscqX+Uka!g49AxSc?z8nuAGP4I4Ix6`<-PO~U$IFfcO zta2y<$zlgm0lw>i?S)^P`~{K@aApldWqNc!D_&t4}Fw~5O#>JxhNTBvi*7-YcTzsmm`G5`Po literal 0 HcmV?d00001 diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index cc03d349b8..9aa2654554 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -208,7 +208,9 @@ describe('TranscriptAdapter', () => { source: { kind: 'plugin', plugin: 'compact' }, }) }), ]) - expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }]) + expect(adapter.nodes()).toMatchObject([{ + kind: 'context', seq: 0, provenance: { role: 'inject', label: 'compact' }, + }]) }) it('ignores a foreign plugin s replacement user/message', () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index bf1e21a541..3c9b38b984 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/ui-conversation/README.md -README.md: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d -README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246 +README.md: c7ac25a47eceb2f06ae90bf540a37b77c67a0d8a +README.zh.md: 1b1d250b8623aa164119e85caa2631805b2879a4 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7d3a4b5fe0..c7ac25a47e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). A durable or pending steering bubble carries an `插话` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d93af91381..1b1d250b86 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill 目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。持久与待处理的 steering(中途引导)气泡上方带有 `插话` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css index e603931a27..a3e5458096 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css @@ -12,6 +12,20 @@ color: var(--dsw-alias-label-secondary); } +/* Producer name beside the role title: ToolRow's summary geometry, so the two + disclosure rows keep one 24px rhythm; subordinate color, truncated when long. */ +.source { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + margin-left: 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 14px; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + .body { box-sizing: border-box; width: calc(100% - 22px); diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index 4f7bbff348..e5f9ee8567 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -48,16 +48,22 @@ function inlineJson(payload: unknown): string { export interface ContextInjectionRowProps { content: ContextMessageNode['content'] source: ContextMessageNode['source'] + /** Role and producer name projected from the durable source. */ + provenance: ContextMessageNode['provenance'] /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } /** * Render logged context with the Tool calls disclosure chrome from Figma. - * @param props - Durable content and source provenance. + * + * The header names the role the context plays and, beside it, the producer the + * durable source identifies, so a reader can tell an injected skill catalog + * from a workspace instruction file or a recalled session without expanding. + * @param props - Durable content, its projected provenance, and the locale seat. * @returns A collapsed context row with a bounded JSON body. */ -export function ContextInjectionRow({ content, source, t }: ContextInjectionRowProps) { +export function ContextInjectionRow({ content, source, provenance, t }: ContextInjectionRowProps) { const [open, setOpen] = useState(false) const body = useMemo(() => { if (!open) return '' @@ -72,7 +78,11 @@ export function ContextInjectionRow({ content, source, t }: ContextInjectionRowP className={css.root} icon={} chevronClassName={css.chevron} - title={t('message.contextInjection')} + title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} + collapsedContent={provenance.label === null ? undefined : ( + {`· ${provenance.label}`} + )} + keepContentWhenOpen open={open} expandable expandOnRowClick diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 323c5a5769..5c07ace71e 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -8,6 +8,15 @@ gap: 6px; } +/* Steering caption above the bubble: mid-turn interjections carry the same + bubble as a turn-opening prompt, so the transcript names which one this is. */ +.steeringMark { + padding-right: 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 16px; +} + .bubble { /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */ max-width: min(525px, 82%); diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a0bed9a38e..73dbcdb509 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -171,19 +171,22 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, actions, pending = false, t, + content, actions, pending = false, steering = false, t, }: { content: readonly unknown[] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean + /** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */ + steering?: boolean t: ChatViewSlotProps['t'] }): ReactNode { const { text, rest } = contentText(content) const truncated = (total: number): string => t('json.truncated', { total }) return (
+ {steering && {t('message.steering')}}
{projectUserText(text)} {rest.map((block, i) => )} @@ -207,6 +210,7 @@ export function PendingSteeringBubble({ content, t }: { ( ( + ) case 'compaction': return diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 7a38aaa0f8..fe20201387 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -52,6 +52,8 @@ export const zh = { 'chat.toBottom': '回到底部', 'message.extraBlock': '附加内容块', 'message.contextInjection': '上下文注入', + 'message.contextRecall': '跨会话召回', + 'message.steering': '插话', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', @@ -163,6 +165,8 @@ export const en = { 'chat.toBottom': 'Back to bottom', 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', + 'message.contextRecall': 'Session recall', + 'message.steering': 'Steer', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 89a09ca28d..c74defc4f7 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -130,7 +130,7 @@ describe('MessageItem arms', () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) }) - it('consumed steering renders copy and branch actions without a badge', () => { + it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -145,7 +145,7 @@ describe('MessageItem arms', () => { onFork={fork} />, ) - expect(view.queryByText('插话')).toBeNull() + expect(view.getByText('插话')).toBeTruthy() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) @@ -161,10 +161,11 @@ describe('MessageItem arms', () => { seq: 3, content: [{ type: 'text', text: 'x\n"y":,[{}]' }], source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] }, + provenance: { role: 'inject', label: 'fixture' }, } as never} />, ) - const disclosure = ctxView.getByRole('button', { name: '上下文注入' }) + const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*· fixture$/ }) expect(disclosure.getAttribute('aria-expanded')).toBe('false') expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull() expect(ctxView.container.querySelector('svg')).not.toBeNull() @@ -187,6 +188,7 @@ describe('MessageItem arms', () => { seq: 3, content: [{ type: 'text', text: 'x'.repeat(21_000) }], source: null, + provenance: { role: 'inject', label: null }, } as never} />, ) @@ -195,6 +197,50 @@ describe('MessageItem arms', () => { .toMatch(/… 已截断,共 \d+ 字符$/) }) + it('a recalled session titles its row by role and names the sessions it read', () => { + const view = render( + , + ) + expect(view.getByRole('button', { name: /^跨会话召回\s*· 重构 loader$/ })).toBeTruthy() + expect(view.queryByText('上下文注入')).toBeNull() + }) + + it('keeps the producer name visible while the context body is expanded', () => { + const view = render( + , + ) + const disclosure = view.getByRole('button', { name: /^上下文注入\s*· AGENTS\.md$/ }) + fireEvent.click(disclosure) + expect(disclosure.getAttribute('aria-expanded')).toBe('true') + expect(view.container.querySelector('[data-context-source]')?.textContent).toBe('· AGENTS.md') + }) + + it('a context source that names no producer shows the role alone', () => { + const view = render( + , + ) + expect(view.getByRole('button', { name: '上下文注入' })).toBeTruthy() + expect(view.container.querySelector('[data-context-source]')).toBeNull() + }) + it('unknown nodes retain the generic JSON row', () => { const unknownView = render( , diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 3a0ca4f7b0..3301bf5af6 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -380,6 +380,9 @@ describe('ChatView', () => { expect(view.queryByText('later')).toBeNull() const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]') expect(pendingBubble).not.toBeNull() + // Pending and durable steering carry the same interjection caption, so the + // hand-off does not change what the row says it is. + expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy() fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('interrupt now') expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull() @@ -401,6 +404,7 @@ describe('ChatView', () => { }) expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() + expect(view.getAllByText('插话')).toHaveLength(1) expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' }) @@ -447,6 +451,7 @@ describe('ChatView', () => { const nextRetry = { ...retry(3), turn: 2, retry: 2 } const context = { kind: 'context', seq: 4, time: 4_000, content: [], source: null, + provenance: { role: 'inject', label: null }, } as const satisfies ConversationNode const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true }) const view = render() diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts index ae1fecbaea..0e63a24a48 100644 --- a/packages/client/ui-trajectory/tests/context-branches.spec.ts +++ b/packages/client/ui-trajectory/tests/context-branches.spec.ts @@ -13,6 +13,7 @@ const checkpoint = { time: 100, content: [], source: { kind: 'plugin', plugin: 'compact' }, + provenance: { role: 'inject', label: 'compact' }, } as ConversationNode const abandoned = { From 95a89078d53db942bac85f2dce16f04fbbbb19a4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 17:53:26 +0800 Subject: [PATCH 2/9] docs(web): refresh the MessageItem module comment for the steering caption --- .../client/ui-conversation/src/client/chat/MessageItem.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 73dbcdb509..b15bd2f987 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,7 +1,8 @@ // MessageItem: simple chat nodes — user and consumed-steering bubbles -// (right-aligned, with clock + copy / branch IconActions), pending steering -// (copy only), context injection, compaction marker, retry disclosure, and -// unknown-surface JSON rows. +// (right-aligned, with clock + copy / branch IconActions; steering adds the +// interjection caption that names it), pending steering (caption + copy only), +// context injection, compaction marker, retry disclosure, and unknown-surface +// JSON rows. import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' From fab131a3666c86d565575c5d205ed248199d7c3a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 18:25:44 +0800 Subject: [PATCH 3/9] fix(web): align the context separator with ToolRow and name the caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the context provenance row: - the producer name now follows ToolRow's aria-hidden separator dot instead of a literal middot inside the text, so the two disclosure rows expose one accessible-name shape; - the English steering caption becomes the noun `Interjection`, parallel to the Chinese 插话 and to the label this bubble carried before; - the history fold gets its own provenance assertion, so the mapping it declares separately from TranscriptAdapter is pinned on both sides; - the superseded-in-part bullet on the no-interjection-chrome note now enumerates the third Decision bullet too, whose no-badge and no-IconActions clauses had both lapsed. --- ...eer-entry-or-interjection-chrome.i18n.yaml | 4 ++-- ...i-no-steer-entry-or-interjection-chrome.md | 2 +- ...o-steer-entry-or-interjection-chrome.zh.md | 2 +- apps/web/tests/seeded-history.e2e.ts | 6 ++--- .../snapshots/bash-abort-row/ui.expected.md | 4 ++-- .../snapshots/code-mode-round/ui.expected.md | 4 ++-- .../cordis-tool-round/ui.expected.md | 4 ++-- .../snapshots/fresh-round-trip/ui.expected.md | 4 ++-- .../lifecycle-chrome/reloaded.expected.md | 4 ++-- .../live-interactions/cancel.expected.md | 4 ++-- .../live-interactions/error-auth.expected.md | 4 ++-- .../live-interactions/loading.expected.md | 4 ++-- .../live-interactions/retry.expected.md | 4 ++-- .../plan-review/approved.expected.md | 4 ++-- .../question-composer/answered.expected.md | 4 ++-- .../queue-actions/collapsed.expected.md | 4 ++-- .../queue-actions/editing.expected.md | 4 ++-- .../queue-actions/layout.expected.md | 12 +++++----- .../queue-actions/preserved.expected.md | 4 ++-- .../snapshots/queue-actions/ui.expected.md | 4 ++-- .../seeded-history/command-row.expected.md | 4 ++-- .../snapshots/seeded-history/ui.expected.md | 4 ++-- .../snapshots/steering/mid-steer.expected.md | 6 ++--- .../snapshots/steering/settled.expected.md | 6 ++--- .../subagent-conversation/ui.expected.md | 4 ++-- .../snapshots/web-search-round/ui.expected.md | 4 ++-- .../client/runtime/tests/history-fold.spec.ts | 22 ++++++++++++++++++- .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../chat/ContextInjectionRow.module.css | 14 +++++++++--- .../src/client/chat/ContextInjectionRow.tsx | 8 ++++++- .../ui-conversation/src/client/locales.ts | 2 +- .../tests/chat-branch-tails.spec.tsx | 8 +++---- 34 files changed, 105 insertions(+), 71 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml index 2518f0de2d..b5cdee7d4b 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.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 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 15b9b9d299161e9d7404b43cfabfdc11e87d8feb -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 8c25f2700c316e75d29143e85c4c966fcb744c85 +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 9dc87cd69f5d274ba5e8d66c30805cada7f1fa1a +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: e650b660efe0bf308a4b1a8c72e9c89a133ac769 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md index 15b9b9d299..9dc87cd69f 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md @@ -28,7 +28,7 @@ Keep host and runtime steering intact. Remove only the Web UI entry and chrome: ## Consequences -- **Superseded in part.** Composer steering shipped afterwards, so the first Decision bullet and the first consequence below no longer describe master: `InputMachine` carries the `queue`/`steer` mode union again and Web users can steer. The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required and restored the `插话` caption on steering bubbles. Host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript still hold. +- **Superseded in part.** Composer steering shipped afterwards, so the first and third Decision bullets and the first consequence below no longer describe master: `InputMachine` carries the `queue`/`steer` mode union again, Web users can steer, and the steering bubble regained its user IconActions before carrying a caption again. The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required and restored the `插话` caption on steering bubbles. Host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript still hold. - Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls. - Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections. - Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one. diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md index 8c25f2700c..e650b660ef 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md @@ -28,7 +28,7 @@ Status: implemented ## 后果 -- **部分被取代。** composer steering 在此之后落地,因此 Decision 的第一条与下面的第一条后果都已不再描述 master:`InputMachine` 重新携带 `queue`/`steer` mode 联合类型,Web 用户可以 steer。随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策,并恢复了 steering 气泡上的 `插话` 标注。host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍,仍然成立。 +- **部分被取代。** composer steering 在此之后落地,因此 Decision 的第一条与第三条、以及下面的第一条后果都已不再描述 master:`InputMachine` 重新携带 `queue`/`steer` mode 联合类型,Web 用户可以 steer,steering 气泡也早在重新带上标注之前就恢复了用户 IconActions。随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策,并恢复了 steering 气泡上的 `插话` 标注。host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍,仍然成立。 - Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。 - Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。 - 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 718aabbbce..784589d9c7 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -238,7 +238,7 @@ describe('web e2e: seeded history renders through cold resume', () => { })) // The header names the producer the durable source records, so the // reconciled instruction file is readable without expanding the row. - await page.getByRole('button', { name: 'Context injection · AGENTS.md', exact: true }) + await page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true }) .waitFor({ timeout: 10_000 }) }, 60_000) @@ -257,7 +257,7 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection')) - const disclosure = page.getByRole('button', { name: 'Context injection · AGENTS.md', exact: true }) + const disclosure = page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true }) expect(await disclosure.getAttribute('aria-expanded')).toBe('false') const collapsedIcon = disclosure.locator('svg').first() const collapsedIconBox = await collapsedIcon.boundingBox() @@ -368,7 +368,7 @@ describe('web e2e: seeded history renders through cold resume', () => { source: { kind: 'plugin', plugin: 'fixture' }, })) - const disclosure = page.getByRole('button', { name: 'Context injection · fixture', exact: true }) + const disclosure = page.getByRole('button', { name: 'Context injection fixture', exact: true }) await disclosure.waitFor({ timeout: 10_000 }) await disclosure.click() await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index f738eb0ff5..a8f9b82caa 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - 'button "Failed Bash Error: command aborted" [expanded]': - img - text: "Failed Bash Error: command aborted" diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index cde7820e28..3bd030ce88 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - 'button "Think The user wants me to write a single `run_code` program that:"': - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index c3e97df0f9..57261af55d 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to:": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 959e9db772..b259813833 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 2d1aa1854f..1b6e48466a 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to reply with a single word. Let me comply.": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 3b67a54c51..f1c57be871 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 508958f617..be1d936dd2 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - status: - text: This turn failedAPI key is invalid - code: AUTH diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index 9ad29a3bcf..6e81c87205 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index eba44b97c1..bcfd99e77b 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - group: - status: Retried model request (1/2) · {{duration}} - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 18fbf294b5..71876c40ae 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -11,10 +11,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."': - img - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 3cfde08bd2..05c3448ad7 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index 7bbe5f5cb5..cdde5d8790 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - button "2 queued messages" diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index d31126510c..1b08108eca 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - button "2 queued messages" [disabled] [expanded] diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 665eb1ccca..4b96025e07 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -8,18 +8,18 @@ - img - img - text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear" -- button "Context injection · goal": +- button "Context injection goal": - img - img - - text: Context injection · goal -- button "Context injection · goal": + - text: Context injection goal +- button "Context injection goal": - img - img - - text: Context injection · goal -- button "Context injection · @deepseek-ai/dsh-system-prompt": + - text: Context injection goal +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - region "To-dos": diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 866047f95d..f1db583938 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 6476183b5f..2276449812 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - list: diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index 2f0d9334f8..0168ecdfbe 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -37,10 +37,10 @@ - button "Context compacted View compaction summary": - img - text: Context compacted View compaction summary -- button "Context injection · AGENTS.md": +- button "Context injection AGENTS.md": - img - img - - text: Context injection · AGENTS.md + - text: Context injection AGENTS.md - img - text: permission preset read-only - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 565150c4f9..482cd7a3e3 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -37,10 +37,10 @@ - button "Context compacted View compaction summary": - img - text: Context compacted View compaction summary -- button "Context injection · AGENTS.md": +- button "Context injection AGENTS.md": - img - img - - text: Context injection · AGENTS.md + - text: Context injection AGENTS.md - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 93ceadfa28..c32cee0077 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -24,7 +24,7 @@ - img - text: Ask question waiting - status: Deep diving... -- text: "Steer Interjection: include the word BANANA in your final reply." +- text: "Interjection Interjection: include the word BANANA in your final reply." - button "Copy": - img - region "Ready to continue?": diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 2066d12848..c20cab773a 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -22,7 +22,7 @@ - img - img - text: Ask question 1/1 answered -- text: "Steer Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img - button "Branch into a new conversation" [disabled]: diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 8480bf9bbe..401061a3b6 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -15,10 +15,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 567e3f83c3..2b355156e8 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Search DeepSeek Harness snapshot search": - img - img diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts index 0c5421e491..527e324e26 100644 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ b/packages/client/runtime/tests/history-fold.spec.ts @@ -1,4 +1,4 @@ -import { createMessage } from '@deepseek-ai/dsh-llm' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { describe, expect, it } from 'vitest' import { projectConversationHistory } from '../src/client/session-history/history-fold.ts' @@ -8,6 +8,26 @@ const at = (seq: number, event: Record): SessionEvent => ({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent describe('projectConversationHistory', () => { + it('names an injected context node from its durable source, like the live adapter', () => { + // The fold declares its own node mapping (jscpd:ignore in the source), so + // the provenance projection is pinned on both sides independently. + const injected = at(0, { + type: 'user/message', + surfaceOp: 'append', + data: createUserMessage({ + content: [{ type: 'text', text: '' }], + // A plugin source, because the client program does not see the host + // packages that merge richer source kinds; those arms are pinned in + // context-provenance.spec.ts. + source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + }), + }) + const { contexts } = projectConversationHistory([{ event: injected }]) + expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{ + kind: 'context', seq: 0, provenance: { role: 'inject', label: 'dsh-tool-skill' }, + }]) + }) + it('projects frozen surface generations without widening the core live surface', () => { const events = [ ev.user(0, 'a'), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 3c9b38b984..9028fca98c 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/ui-conversation/README.md -README.md: c7ac25a47eceb2f06ae90bf540a37b77c67a0d8a -README.zh.md: 1b1d250b8623aa164119e85caa2631805b2879a4 +README.md: 9d715b08ea86dd0825deb51b01037949605a93d4 +README.zh.md: 29c7f9877d1161e8b8f560a2cd61e0e7c9e4cc34 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index c7ac25a47e..9d715b08ea 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). A durable or pending steering bubble carries an `插话` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 1b1d250b86..29c7f9877d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill 目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。持久与待处理的 steering(中途引导)气泡上方带有 `插话` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill 目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。持久与待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css index a3e5458096..a15d66efc3 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css @@ -12,13 +12,21 @@ color: var(--dsw-alias-label-secondary); } -/* Producer name beside the role title: ToolRow's summary geometry, so the two - disclosure rows keep one 24px rhythm; subordinate color, truncated when long. */ +/* Separator and producer name beside the role title: ToolRow's summary geometry, + so the two disclosure rows keep one 24px rhythm and one separator shape. */ +.sep { + flex: none; + width: 2px; + height: 2px; + margin: 0 8px; + border-radius: 1px; + background: var(--dsw-alias-label-caption); +} + .source { flex: 1 1 auto; min-width: 0; overflow: hidden; - margin-left: 4px; color: var(--dsw-alias-label-tertiary); font-size: 14px; line-height: 24px; diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index e5f9ee8567..31b2092e6e 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -80,7 +80,13 @@ export function ContextInjectionRow({ content, source, provenance, t }: ContextI chevronClassName={css.chevron} title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} collapsedContent={provenance.label === null ? undefined : ( - {`· ${provenance.label}`} + /* ToolRow's separator shape: an aria-hidden dot, so the accessible name + stays the two readable parts and the two disclosure rows expose one + name shape. A source that names no producer drops the dot with it. */ + <> + + {provenance.label} + )} keepContentWhenOpen open={open} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index fe20201387..005e5cad24 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -166,7 +166,7 @@ export const en = { 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', - 'message.steering': 'Steer', + 'message.steering': 'Interjection', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index c74defc4f7..a784d1555e 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -165,7 +165,7 @@ describe('MessageItem arms', () => { } as never} />, ) - const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*· fixture$/ }) + const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*fixture$/ }) expect(disclosure.getAttribute('aria-expanded')).toBe('false') expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull() expect(ctxView.container.querySelector('svg')).not.toBeNull() @@ -208,7 +208,7 @@ describe('MessageItem arms', () => { } as never} />, ) - expect(view.getByRole('button', { name: /^跨会话召回\s*· 重构 loader$/ })).toBeTruthy() + expect(view.getByRole('button', { name: /^跨会话召回\s*重构 loader$/ })).toBeTruthy() expect(view.queryByText('上下文注入')).toBeNull() }) @@ -223,10 +223,10 @@ describe('MessageItem arms', () => { } as never} />, ) - const disclosure = view.getByRole('button', { name: /^上下文注入\s*· AGENTS\.md$/ }) + const disclosure = view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md$/ }) fireEvent.click(disclosure) expect(disclosure.getAttribute('aria-expanded')).toBe('true') - expect(view.container.querySelector('[data-context-source]')?.textContent).toBe('· AGENTS.md') + expect(view.container.querySelector('[data-context-source]')?.textContent).toBe('AGENTS.md') }) it('a context source that names no producer shows the role alone', () => { From 340c9ba57647b6968805095d914b475f2afe472b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 5 Aug 2026 12:15:03 +0800 Subject: [PATCH 4/9] feat(web): split context injection into producer-declared forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Injected context reached the transcript as one anonymous shape whose expanded body was the whole message serialized as inline JSON, so the escaping collapsed the only readable part — the model-facing prose — into a single line. `MessageSource` gains an optional producer-declared `form`, a small semantic vocabulary of information shapes independent of `kind`: kind says who produced the context, form says what shape it is, so several producers may share one presentation. Two values ship. `instructions` (workspace-context) lists the reconciled files above their text and keeps the `` framing verbatim, because the framing is part of what the model read. `catalog` moves dsh-tool-skill off the shared plugin kind onto a `skill-catalog` source carrying the published name/description entries, and the body lists those instead of re-parsing `` out of the prose. Catalog identity moves with it: the republish digest now covers the durable entries, deleting the text-slicing that recovered them from a logged message. Everything else renders the opaque body — the model-facing text with its real line breaks, then the remaining provenance as fields. That is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here, which is why the classification lives in the durable source rather than a client-side table keyed by producer. --- ...b-context-source-and-steer-marks.i18n.yaml | 4 +- ...8-04-web-context-source-and-steer-marks.md | 2 +- ...4-web-context-source-and-steer-marks.zh.md | 2 +- ...26-08-05-context-form-vocabulary.i18n.yaml | 6 + .../2026-08-05-context-form-vocabulary.md | 58 +++++ .../2026-08-05-context-form-vocabulary.zh.md | 58 +++++ apps/web/tests/seeded-history.e2e.ts | 9 +- docs/config-catalog.md | 2 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/core.zh.md | 2 +- .../code-mode-workspace-context/session.jsonl | 4 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../tests/snapshots/skill-load/session.jsonl | 2 +- .../snapshots/workspace-context/session.jsonl | 6 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/pty-tools/session.jsonl | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/src/client/index.ts | 4 +- .../client/session-history/history-fold.ts | 3 +- .../src/client/sessions/context-provenance.ts | 25 ++ .../src/client/sessions/conversation.ts | 4 +- .../src/client/sessions/transcript-adapter.ts | 3 +- .../runtime/tests/context-provenance.spec.ts | Bin 3196 -> 4148 bytes .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/ContextBody.module.css | 96 ++++++++ .../src/client/chat/ContextBody.tsx | 227 ++++++++++++++++++ .../chat/ContextInjectionRow.module.css | 3 +- .../src/client/chat/ContextInjectionRow.tsx | 67 ++---- .../src/client/chat/MessageItem.tsx | 1 + .../ui-conversation/src/client/locales.ts | 6 + .../tests/chat-branch-tails.spec.tsx | 85 ++++++- .../ui-conversation/tests/chat-view.spec.tsx | 1 + .../tests/context-branches.spec.ts | 1 + .../context/workspace-context/src/index.ts | 1 + .../context/workspace-context/src/state.ts | 4 +- .../tests/workspace-context.spec.ts | 9 +- .../cordis/tool-cordis/src/api-catalog.ts | 10 +- .../agent-spine-demo/tests/agent-core.spec.ts | 14 +- packages/llm/llm/src/message.ts | 29 ++- packages/skill/tool-skill/README.i18n.yaml | 4 +- packages/skill/tool-skill/README.md | 2 +- packages/skill/tool-skill/README.zh.md | 2 +- packages/skill/tool-skill/src/index.ts | 105 ++++---- .../skill/tool-skill/tests/tool-skill.spec.ts | 60 +++-- 51 files changed, 779 insertions(+), 174 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md create mode 100644 .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md create mode 100644 packages/client/ui-conversation/src/client/chat/ContextBody.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/ContextBody.tsx diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index c7695cfd3f..ed4205dbe2 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.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 .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: 5d158039b742af58a91b0e9d194b98e89485d8d7 -2026-08-04-web-context-source-and-steer-marks.zh.md: 46f5285033671407c1d26794cfd4ba74c06a8ad9 +2026-08-04-web-context-source-and-steer-marks.md: dca08d003df810932a2b5565e42d167781d593f7 +2026-08-04-web-context-source-and-steer-marks.zh.md: b0dc4c880778df86965e2732a69aaddd0c85d80f diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index 5d158039b7..dca08d003d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -14,7 +14,7 @@ The distinctions are already durable. `user/message.source` is the merge-extensi The transcript names all three roles a non-prompt message can play — injected context, recalled session, and steering. -`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the expanded body, its 141px scrollport, and the truncation policy are unchanged from the [disclosure decision](2026-07-30-web-context-injection-disclosure.md). +`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the 141px scrollport and truncation bound are unchanged from the [disclosure decision](2026-07-30-web-context-injection-disclosure.md). What renders inside that scrollport is chosen by the independent form axis added in the [context form decision](2026-08-05-context-form-vocabulary.md). **The label is read out of the log, never from a client-side table of producer names.** `workspace-instructions` is named by the distinct instruction paths it reconciled, `session-reference` by the titles of the sessions it read, a plugin source by its logged plugin id, and any other source by its own `kind` — the documented default arm for a merge-extensible union. A source carrying no readable kind degrades to an unnamed injection. A new or renamed producer is therefore identifiable without a client release, no label can go stale against the code, and a resumed, forked, or foreign log projects exactly like a live session. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index 46f5285033..b0dc4c8807 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -14,7 +14,7 @@ Status: implemented transcript 为非提示消息可能承担的三种角色分别命名:注入上下文、召回会话、steering。 -`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;展开内容区、141px 滚动视口与截断策略沿用[展开项决策](2026-07-30-web-context-injection-disclosure.md),未作改动。 +`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;141px 滚动视口与截断上限沿用[展开项决策](2026-07-30-web-context-injection-disclosure.md),未作改动。视口里渲染什么,则由[上下文形态决策](2026-08-05-context-form-vocabulary.md)引入的、相互独立的形态轴决定。 **名称从日志中读出,绝不来自客户端维护的生产者名称表。** `workspace-instructions` 以它对账过的去重指令文件路径命名,`session-reference` 以它读取的会话标题命名,插件来源以其记录的插件 id 命名,其余来源则以自身的 `kind` 命名——这正是可合并扩展联合类型有文档记载的默认分支。没有可读 kind 的来源降级为无名注入。于是新增或重命名的生产者无需客户端发版即可辨识,任何名称都不会相对代码变味,恢复、fork 或来自外部的日志与实时会话的投影结果完全一致。 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml new file mode 100644 index 0000000000..aeba2cd585 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.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-08-05-context-form-vocabulary.md +2026-08-05-context-form-vocabulary.md: c2369a21a474e677eb324b6f8d2457091ee44e30 +2026-08-05-context-form-vocabulary.zh.md: 3c16279a4e36742eb7c5d60aa1c5ad9a2ae07cd6 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md new file mode 100644 index 0000000000..c2369a21a4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md @@ -0,0 +1,58 @@ +# Agent Note: Producer-declared context forms + +Status: implemented + +English | [中文](2026-08-05-context-form-vocabulary.zh.md) + +## Problem + +Every logged non-user `user/message` rendered through one body: the whole message serialized as inline JSON. A reader opening a row met `{ "content": [ { "type": "text", "text": "…\n\n…" } ], "source": { … } }`, where the escaping had collapsed the only thing worth reading — the model-facing prose — into a single line, and the provenance sat inside the same blob. + +Naming the producer in the header (the [source and steer marks decision](2026-08-04-web-context-source-and-steer-marks.md)) fixed *who added this*. It could not fix *what kind of thing was added*, because nothing in the log said so. Injected context is not one shape: a reconciled `AGENTS.md`, a catalog of available skills, a runtime policy snapshot, and a subagent's report are as different from each other as a terminal card is from a diff card, yet all four presented as the same wall of escaped JSON. + +The tool surface already solved this shape. `ToolCallView` has three cards, not one per tool, and a tool declares which card its call is. Context had no equivalent: no vocabulary of shapes, and no way for a producer to say which one it emits. + +## Decision + +`MessageSource` gains an optional producer-declared `form: ContextForm` — a small tagged vocabulary of information *shapes*, independent of `kind`: + +- `kind` answers **who produced this** and remains pure provenance. +- `form` answers **what shape of information it is**. Several producers may share one form, and one producer may emit more than one over a session. + +The vocabulary is semantic, never visual. A value states that the content is a file's instructions or a catalog of available items; colors, icons, ordering, and collapse defaults are the consumer's business and must not enter the union. It grows one value at a time, as producers gain the structured fields their form needs. This release declares two: + +**`instructions`** — instructions read out of workspace files. `workspace-context` declares it on both the startup baseline and later deltas; its existing `changes[]` already carried the paths, actions, and digests the presentation needs, so no field was added. The body lists the reconciled files above the text, and keeps the `` framing verbatim: the framing is part of what the model read, so hiding it would misreport the request. + +**`catalog`** — a catalog of items available this session, republished as it changes. `dsh-tool-skill` moves off the shared `plugin` kind to its own `skill-catalog` source carrying `entries` (the exact `name`/`description` pairs published) and `update` on a replacement. The body lists those entries instead of re-parsing the `` block out of the prose. + +That move also relocates catalog **identity**: the republish digest now covers the durable entries rather than the rendered text, so the model-facing framing can no longer decide whether a republish is needed, and the text-slicing that recovered entries from a logged message is gone. A resumed session whose newest catalog predates this change republishes once, which the pre-release stance permits. + +Everything else — including a form this UI version does not present, a form absent from the source, and a `catalog` whose entries are unusable — renders the **opaque** body: the model-facing text with its real line breaks, then the remaining provenance as fields. Opaque is the documented default, not a leftover bin. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer. + +## Why not a presenter registry + +The tool seam pairs its vocabulary with `presentCall(args)`, a host-side pure function each tool implements. Context deliberately has no equivalent, because the input differs in ownership: a tool's `args` are generated by the **model** against a model-facing schema, so a translation step is unavoidable; a context `source` is constructed by the **producing plugin** itself, under no external constraint, and can simply record the facts a presentation needs. Adding a registry would have bought a translation nobody needs, at the cost of a host computation point, a wire field per context message, and a browser bundle for every producing package (the client purity gate forbids host packages from contributing components). + +## Alternatives considered + +**Map source kinds to renderers in the client.** Cheapest to write and requires no format change, but it puts producer knowledge back in the client: every new kind then needs a client release to render as anything but opaque, and a foreign log cannot be classified at all. It also reintroduces exactly the coupling the [source and steer marks decision](2026-08-04-web-context-source-and-steer-marks.md) removed for labels. + +**Reuse `kind` as the form.** One discriminant is simpler, and `workspace-instructions` is already 1:1 with its form. It breaks on the shared shapes: three producers emit runtime snapshots today, and folding them into one kind would erase their provenance. Two axes keep provenance exact while letting presentations be shared. + +**Let the client parse the model-facing prose.** The entries and file sections are visibly structured in the text. Parsing them couples the presentation to prompt wording, so every reword silently breaks a card — the same reason catalog identity moved off the text. + +**Render instructions as Markdown.** The body is a Markdown file and would read better rendered. The text also carries `` framing, which the Markdown renderer drops as raw HTML, so a Markdown body would silently hide part of what the model read. Deferred until the producer records per-file content structurally. + +## Testing + +- `packages/client/runtime` pins the form projection, including the unknown, empty, wrongly-typed, and absent values that must degrade to opaque. +- `packages/client/ui-conversation` pins each body: the opaque body's preserved line breaks and provenance fields, the instructions body's file list and verbatim framing, the catalog body's entry list, and a catalog with unusable entries falling back to opaque. +- `packages/skill/tool-skill` pins the new source on first publication and replacement, and republish behavior driven by the durable entries. +- The keyless assembled-Web seeded-history scenario expands a real `instructions` context in Chromium and asserts its file list, verbatim framing, and the unchanged disclosure geometry. `catalog` has no assembled coverage: the hermetic scaffold publishes no skills, so no catalog reaches a browser scenario. + +## Consequences + +- A reader can tell what was added without expanding, and reading it no longer means reading escaped JSON. +- The durable `MessageSource` now carries a semantic classification beside provenance. The boundary is load-bearing: facts and shape only, never presentation. A producer that wants a better card records better facts. +- Catalog identity no longer depends on the model-facing prose, deleting the text-slicing path that could mistake a reworded catalog for a changed one. +- Five forms from the surveyed taxonomy (`snapshot`, `relay`, `recall`, `notice`, and the unclassified remainder) are not declared yet; their producers render opaque until they record the fields their form needs. diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md new file mode 100644 index 0000000000..3c16279a4e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md @@ -0,0 +1,58 @@ +# Agent Note:由生产方声明的上下文形态 + +Status: implemented + +[English](2026-08-05-context-form-vocabulary.md) | 中文 + +## Problem + +每一条已记录的非用户 `user/message` 都通过同一个内容区渲染:把整条消息序列化成内联 JSON。读者展开一行,看到的是 `{ "content": [ { "type": "text", "text": "…\n\n…" } ], "source": { … } }`——转义把唯一值得读的东西(面向模型的散文)压成了一行,而来源信息又和它挤在同一坨里。 + +在标题栏写出生产者([来源与 steer 标识决策](2026-08-04-web-context-source-and-steer-marks.md))解决了「这是谁加的」。它解决不了「加进来的是什么东西」,因为日志里根本没有这句话。注入上下文不是一种形状:对账后的 `AGENTS.md`、可用 skill 的目录、运行时策略快照、子 agent 的汇报,彼此之间的差别不亚于终端卡片与 diff 卡片,然而这四者呈现出来是同一堵转义 JSON 的墙。 + +工具面早就解决过同一个形状问题。`ToolCallView` 只有三种卡片,而不是每个工具一种,由工具自己声明本次调用属于哪一种。上下文没有对应物:既没有形状词汇表,生产方也无从声明自己发出的是哪一种。 + +## Decision + +`MessageSource` 新增一个可选、由生产方声明的 `form: ContextForm`——一份关于信息**形状**的小型 tagged 词汇表,与 `kind` 相互独立: + +- `kind` 回答**由谁产生**,保持纯粹的溯源语义。 +- `form` 回答**这是何种形态的信息**。多个生产方可以共用一种形态,一个生产方在一次会话中也可以发出多种。 + +该词汇表是语义的,绝不涉及视觉。取值只陈述「内容是某个文件的指令」或「是一份可用项目录」;颜色、图标、排序、默认折叠状态归消费方管,不得进入这个联合类型。它随生产方补齐各自形态所需的结构化字段而逐个增长。本次声明两个: + +**`instructions`**——从工作区文件中读出的指令。`workspace-context` 在启动基线与后续增量上都声明它;其既有的 `changes[]` 已经携带了呈现所需的路径、动作与 digest,因此没有新增字段。内容区在正文之上列出对账过的文件,并原样保留 `` 包装:那层包装本就是模型读到的一部分,隐藏它会歪曲这次请求。 + +**`catalog`**——本会话可用项的目录,随变化重新发布。`dsh-tool-skill` 从共享的 `plugin` kind 迁到自有的 `skill-catalog` 来源,携带 `entries`(本次发布的 `name`/`description` 对)与替换目录上的 `update`。内容区直接列出这些条目,不再从散文里反解 `` 块。 + +这次迁移同时挪动了目录的**身份**:重新发布用的 digest 现在覆盖持久条目而非渲染文本,于是面向模型的包装再也无法左右是否需要重新发布,那段从已记录消息里切出条目的文本切分逻辑也随之删除。若恢复的会话中最新目录早于本次改动,会重新发布一次——发布前阶段的姿态允许这样做。 + +其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源信息列成字段。opaque 是有文档的默认,不是兜底垃圾桶。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。 + +## 为什么不做 presenter 注册表 + +工具接缝把它的词汇表与 `presentCall(args)` 配对,那是每个工具在 host 侧实现的纯函数。上下文刻意不设对应物,因为输入的归属不同:工具的 `args` 由**模型**按面向模型的 schema 生成,翻译步骤无法回避;而上下文的 `source` 由**生产方插件**自己构造,不受任何外部约束,完全可以直接记录呈现所需的事实。加一层注册表买到的是一次没人需要的翻译,代价却是一个 host 计算点、每条上下文消息一个 wire 字段、以及每个生产方包都要出浏览器 bundle(客户端纯度门禁禁止 host 包贡献组件)。 + +## Alternatives considered + +**在客户端把来源 kind 映射到渲染器。** 写起来最省,也不用改格式,但它把生产方知识放回了客户端:此后每新增一个 kind 都要客户端发版才能渲染成 opaque 以外的东西,而外部日志根本无法分类。它还会重新引入[来源与 steer 标识决策](2026-08-04-web-context-source-and-steer-marks.md)刚为名称去掉的那种耦合。 + +**复用 `kind` 充当形态。** 单一判别式更简单,`workspace-instructions` 本来也与它的形态一一对应。但它在共享形状上就崩了:今天有三个生产方发出运行时快照,把它们并成一个 kind 会抹掉各自的溯源。两根轴既保住溯源的精确,又让呈现可以共享。 + +**让客户端解析面向模型的散文。** 条目与文件分节在文本里确实有可见结构。解析它们会把呈现耦合到 prompt 措辞上,于是每改一次文案就静默碎掉一张卡——这也正是目录身份从文本上迁走的原因。 + +**把 instructions 渲染成 Markdown。** 正文本来就是 Markdown 文件,渲染出来更好读。但文本同时携带 `` 包装,Markdown 渲染器会把它当原始 HTML 丢弃,于是 Markdown 内容区会悄悄隐藏模型读到的一部分。推迟到生产方按文件结构化记录内容之后再做。 + +## Testing + +- `packages/client/runtime` 钉住形态投影,包括必须降级为 opaque 的未知值、空值、类型不符与缺失。 +- `packages/client/ui-conversation` 逐个钉住内容区:opaque 的换行留存与来源字段、instructions 的文件列表与原样包装、catalog 的条目列表,以及条目不可用的 catalog 回落到 opaque。 +- `packages/skill/tool-skill` 钉住首次发布与替换时的新来源,以及由持久条目驱动的重新发布行为。 +- 无密钥的组装 Web seeded-history 场景在 Chromium 中展开一条真实的 `instructions` 上下文,断言其文件列表、原样包装与未改动的展开项几何。`catalog` 没有组装态覆盖:隔离脚手架不发布任何 skill,因此没有目录能进入浏览器场景。 + +## Consequences + +- 读者不展开就能知道加进来的是什么,展开之后读到的也不再是转义 JSON。 +- 持久 `MessageSource` 现在在溯源之外还承载一个语义分类。这条边界是承重的:只放事实与形状,绝不放呈现。想要更好卡片的生产方应当记录更好的事实。 +- 目录身份不再依赖面向模型的散文,删掉了那条可能把「改了措辞」误判为「改了内容」的文本切分路径。 +- 已勘定的分类里还有五种形态(`snapshot`、`relay`、`recall`、`notice`,以及未归类的其余部分)尚未声明;在它们的生产方补齐各自形态所需字段之前,一律渲染 opaque。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 784589d9c7..627e3234a8 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -227,6 +227,7 @@ describe('web e2e: seeded history renders through cold resume', () => { }], source: { kind: 'workspace-instructions', + form: 'instructions', baseline: true, changes: [{ action: 'set', @@ -268,6 +269,10 @@ describe('web e2e: seeded history renders through cold resume', () => { await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') const body = page.locator('[data-context-injection-body]') await body.waitFor({ timeout: 5_000 }) + // The instructions form names the file it reconciled above the text, and + // the text keeps the framing the model read rather than a cleaned excerpt. + expect(await body.locator('[data-context-files] li').allInnerTexts()).toEqual(['AGENTS.md\nloaded']) + expect(await body.locator('[data-context-text]').innerText()).toContain('') const headerBox = await disclosure.boundingBox() const bodyBox = await body.boundingBox() if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable') @@ -373,7 +378,9 @@ describe('web e2e: seeded history renders through cold resume', () => { await disclosure.click() await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') - const body = page.locator('[data-context-injection-body]') + // The instructions row above stays expanded from the geometry case; the + // opaque body is the one without a declared form. + const body = page.locator('[data-context-injection-body]:not([data-context-form])') const bodyBox = await body.boundingBox() if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable') expect(bodyBox.height).toBeLessThan(141) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5d238761d9..fc8e1568ea 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1916,7 +1916,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:30`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:58`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-str-replace-editor` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 415d74e6f6..b45a0ce588 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 docs/core-data-structures/core.md -core.md: 1b5704384157688b45ae0900bf2d9924426bbd6b -core.zh.md: 05802039920163ac8185703ab483c5603087ed96 +core.md: 6c12b8af07498590bb8eb5b92c3c8f6bbef313f3 +core.zh.md: f83e8873adfae5ab81c02678605f4014214b96b6 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1b57043841..6c12b8af07 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -161,7 +161,7 @@ Where a message came from is itself a merge-extensible sum type: */ interface MessageSourceMap { user: { kind: 'user' } - plugin: { kind: 'plugin'; plugin: string } + plugin: { kind: 'plugin'; plugin: string } & ContextFormed model: ModelMessageSource tool: ToolMessageSource } diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 0580203992..f83e8873ad 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -167,7 +167,7 @@ interface Message { */ interface MessageSourceMap { user: { kind: 'user' } - plugin: { kind: 'plugin'; plugin: string } + plugin: { kind: 'plugin'; plugin: string } & ContextFormed model: ModelMessageSource tool: ToolMessageSource } diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 4588ebb898..6c6efd8b89 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1785014475015,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"},"role":"user","id":"a5066d26-ed57-4f98-8672-b34e883e1299"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014475022,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"user/message","seq":3,"time":1785122256262,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"c9aaa351-f7e6-40ef-955a-c5b8ee07667f"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464674590,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464674590,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464674590,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464674590,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487644564,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -20,7 +20,7 @@ {"type":"tool/code-dispatch-start","seq":105,"time":1785487644629,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} {"type":"tool/code-dispatch","seq":106,"time":1785487644632,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} {"type":"tool/result","seq":107,"time":1785487644634,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hD8d0VcXXFVMtn64GSoC9264"},"content":[{"type":"tool-result","toolCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"{{cwd}}/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"51eadd14-c38c-4ca2-ba35-f5e0d76a31f6"}},"sourceEventSeqs":[104],"surfaceOp":"append"} -{"type":"user/message","seq":108,"time":1785487644634,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"6e280d70-19bc-41fd-91f1-2f03885114c6"},"surfaceOp":"append"} +{"type":"user/message","seq":108,"time":1785487644634,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"6e280d70-19bc-41fd-91f1-2f03885114c6"},"surfaceOp":"append"} {"type":"step/end","seq":109,"time":1785487644635,"data":{"turn":1,"step":1}} {"type":"step/start","seq":110,"time":1785487644641,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":111,"time":1785014477475,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 2bbe6dd86b..d85d672bc9 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -13,7 +13,7 @@ {"type":"assistant/chunk","seq":11,"time":1785487622703,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":12,"time":1785487622703,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7c207b09-7f6e-4e53-a5d2-77e0d2bbb474"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} {"type":"tool/call","seq":13,"time":1785487622703,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"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 acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\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 adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\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 type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n } | {\n readonly kind: 'steer';\n };\n export type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable';\n export type InboxItemId = Branded<'InboxItemId'>;\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 LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\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 readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\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 model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\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 requestContext(): RequestContext | 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': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\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 message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\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 origin?: 'subagent';\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 SteeringOutcome = {\n readonly status: 'admitted';\n readonly turn: number;\n readonly step: number;\n } | {\n readonly status: 'rejected';\n };\n export interface SteeringReceipt {\n readonly outcome: Promise;\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?: UserMessage[];\n readonly concludesTurn?: never;\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?: UserMessage[];\n readonly concludesTurn?: true;\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 ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\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 interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): 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 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 retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"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 acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\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 type ContextForm = 'instructions' | 'catalog';\n export interface ContextFormed {\n readonly form?: ContextForm;\n }\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 adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\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 type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n } | {\n readonly kind: 'steer';\n };\n export type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable';\n export type InboxItemId = Branded<'InboxItemId'>;\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 LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\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 readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\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 } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\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 requestContext(): RequestContext | 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': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\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 message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\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 origin?: 'subagent';\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 SteeringOutcome = {\n readonly status: 'admitted';\n readonly turn: number;\n readonly step: number;\n } | {\n readonly status: 'rejected';\n };\n export interface SteeringReceipt {\n readonly outcome: Promise;\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?: UserMessage[];\n readonly concludesTurn?: never;\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?: UserMessage[];\n readonly concludesTurn?: true;\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 ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\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 interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): 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 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 retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1785487622726,"data":{"turn":1,"step":1}} {"type":"step/start","seq":16,"time":1785487622735,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":17,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 81703a533d..dd89efb794 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"2243ae1a-2d65-4f9c-a972-d360b8cc08aa"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783654655603,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"user/message","seq":3,"time":1784903324926,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"021a7fcb-3d54-4ed9-8c2c-ca7565599fd8"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464638477,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"22b51be2-4727-4990-96cd-7017c137152e"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464638477,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"22b51be2-4727-4990-96cd-7017c137152e"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464638477,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464638478,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487588943,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 0a568d3460..85c17f6948 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"7cb62d32-ef8e-4d45-9b5e-d2a1fbdbabbd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt, then read scope\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"6dd61dad-f320-4dda-a481-63ee420df9af"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464650864,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"6dd61dad-f320-4dda-a481-63ee420df9af"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464650864,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464650864,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487608778,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -15,7 +15,7 @@ {"type":"assistant/message","seq":13,"time":1785487608779,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6908b415-6aca-462d-9d91-0b27a73ba08c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785487608779,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} {"type":"tool/result","seq":15,"time":1785487608790,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"1144df09-c5e0-4781-8734-55acf6f2d4d0"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"user/message","seq":16,"time":1785487608790,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"ed901b04-9258-4f36-a094-c24127021158"},"surfaceOp":"append"} +{"type":"user/message","seq":16,"time":1785487608790,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"ed901b04-9258-4f36-a094-c24127021158"},"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785487608790,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785487608799,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -26,7 +26,7 @@ {"type":"assistant/message","seq":24,"time":1785487608800,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"268c94fb-859c-4ed9-aa98-3a1ccfa31a6f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1785487608801,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}} {"type":"tool/result","seq":26,"time":1785487608810,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"228be9fc-eacf-4a2e-a475-9d4f46b2606d"},"meta":{"path":"{{cwd}}/scope/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[25],"surfaceOp":"append"} -{"type":"user/message","seq":27,"time":1785487608811,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"f955bfc9-0679-478e-84d8-77e266114c44"},"surfaceOp":"append"} +{"type":"user/message","seq":27,"time":1785487608811,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"f955bfc9-0679-478e-84d8-77e266114c44"},"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785487608811,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785487608818,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":30,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} 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 bc159473de..f6f413c2ca 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -4,7 +4,7 @@ {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"subagent/descriptor","seq":3,"time":1785534749329,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":4,"time":1785534749330,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785534749330,"data":{"header":{"config":{"provider":"deepseek-official","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 {{cwd}}.\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- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\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 in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. 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\" | \"temporary\" | \"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 temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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. Temporary Plugins 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 by cordis_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 when unmounted) — 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 /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\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 /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\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 send_message: {\n messageId: string;\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: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: 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 in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. 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","temporary","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 temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"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":"request/header","seq":5,"time":1785534749330,"data":{"header":{"config":{"provider":"deepseek-official","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 {{cwd}}.\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- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\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 in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. 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\" | \"temporary\" | \"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 temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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. Temporary Plugins 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 by cordis_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 when unmounted) — 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 /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\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 /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\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 send_message: {\n messageId: string;\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: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: 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 in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. 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","temporary","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 temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"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":"request/context","seq":6,"time":1785534749330,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"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 a6f1bfc4c7..0e49dfc1f4 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -4,7 +4,7 @@ {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"subagent/descriptor","seq":3,"time":1785534749466,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":4,"time":1785534749467,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785534749468,"data":{"header":{"config":{"provider":"deepseek-official","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 {{cwd}}.\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- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\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 in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. 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\" | \"temporary\" | \"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 temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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. Temporary Plugins 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 by cordis_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 when unmounted) — 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 /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\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 /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\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 send_message: {\n messageId: string;\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: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: 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 in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. 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","temporary","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 temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"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":"request/header","seq":5,"time":1785534749468,"data":{"header":{"config":{"provider":"deepseek-official","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 {{cwd}}.\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- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\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 in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. 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\" | \"temporary\" | \"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 temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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. Temporary Plugins 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 by cordis_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 when unmounted) — 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 /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\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 /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\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 send_message: {\n messageId: string;\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: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: 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 in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. 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","temporary","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 temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"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":"request/context","seq":6,"time":1785534749468,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 255a590d97..c09149f29d 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"50d7fdd8-0423-43a2-b8f4-4aef2829c82e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","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 {{cwd}}.\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- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\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 in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. 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\" | \"temporary\" | \"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 temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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. Temporary Plugins 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 by cordis_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 when unmounted) — 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 /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\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 /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\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 send_message: {\n messageId: string;\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: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: 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 in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. 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","temporary","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 temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"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":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","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 {{cwd}}.\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- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\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 in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. 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\" | \"temporary\" | \"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 temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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. Temporary Plugins 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 by cordis_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 when unmounted) — 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 /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\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 /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\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 send_message: {\n messageId: string;\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: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: 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 in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. 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","temporary","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 temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"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":"request/context","seq":5,"time":1785460681498,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index 19cd2955d2..8fc8cb452c 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -4,7 +4,7 @@ {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"user/message","seq":3,"time":1785464685153,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"da0842e3-2231-4abf-a85f-a16acfb0b305"},"surfaceOp":"append"} {"type":"step/start","seq":4,"time":1785464685153,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464685153,"data":{"header":{"config":{"provider":"deepseek-official","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.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\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\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\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.","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":"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":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"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 start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","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 subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"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":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"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":"request/header","seq":5,"time":1785464685153,"data":{"header":{"config":{"provider":"deepseek-official","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.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\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\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\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.","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":"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":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"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 start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","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 subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"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":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"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":"request/context","seq":6,"time":1785487564325,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 70bb56110b..c9ebfaabba 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: 17b3e73b7c2efa4aa71ff24c9e5a45eaf81771de -README.zh.md: 5fc5981ea3a1f896dddb008271c323e759505dad +README.md: 0d9935571bf84a3ce299a2dd37a81894412ab536 +README.zh.md: 6b6c129ee24ee6a82a21e50924b170c4b9725445 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 17b3e73b7c..0d9935571b 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -28,7 +28,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 5fc5981ea3..6b6c129ee2 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -28,7 +28,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 9ccb534f98..06f88a9131 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -53,7 +53,9 @@ export type { export type { ConversationContext, ConversationContextOriginKind, } from './sessions/conversation-context.ts' -export type { ContextProvenanceView, ContextRole } from './sessions/context-provenance.ts' +export type { + ContextProvenanceView, ContextRole, KnownContextForm, +} from './sessions/context-provenance.ts' export type { ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, } from './sessions/request-inspection.ts' diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index af915b1deb..b1392c8389 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -11,7 +11,7 @@ import type { PartialAssistant, RunningToolCall, } from '../sessions/conversation.ts' import { toAssistantBlocks } from '../sessions/conversation.ts' -import { contextProvenance } from '../sessions/context-provenance.ts' +import { contextForm, contextProvenance } from '../sessions/context-provenance.ts' import type { ConversationContext, ConversationContextOriginKind, } from '../sessions/conversation-context.ts' @@ -128,6 +128,7 @@ function materializeNode( kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, provenance: contextProvenance(event.data.source), + form: contextForm(event.data.source), } } return { diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 3237c1a22e..95d44a04fd 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -89,3 +89,28 @@ export function contextProvenance(source: unknown): ContextProvenanceView { return { role: 'inject', label: kind } } } + +/** + * Context forms this UI version renders with a dedicated presentation. The + * durable vocabulary (`ContextForm` in `dsh-llm`) may already be wider — an + * unrecognized or absent value degrades to the opaque presentation rather than + * dropping the row, so a log written by a newer or foreign producer still + * renders. + */ +const KNOWN_FORMS = ['instructions', 'catalog'] as const + +/** One durable context form this UI version knows how to present. */ +export type KnownContextForm = typeof KNOWN_FORMS[number] + +/** + * Read the producer-declared form off one durable message source. + * @param source - the logged `user/message` source, exactly as recorded. + * @returns the form when this UI version presents it, otherwise null (opaque). + */ +export function contextForm(source: unknown): KnownContextForm | null { + const record = asRecord(source) + const form = record === null ? null : readString(record, 'form') + return form !== null && (KNOWN_FORMS as readonly string[]).includes(form) + ? form as KnownContextForm + : null +} diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index b4c7ebb71b..8e8e69c151 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -12,7 +12,7 @@ import type { InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' -import type { ContextProvenanceView } from './context-provenance.ts' +import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' export type { TodoItem } /** Request configuration recorded for one provider call. */ @@ -126,6 +126,8 @@ export interface ContextMessageNode { source: unknown /** Role and producer name projected from `source` ({@link contextProvenance}). */ provenance: ContextProvenanceView + /** Producer-declared information form ({@link contextForm}); null presents as opaque. */ + form: KnownContextForm | null } /** Durable notice that a closed failed step is waiting for a model-request retry. */ diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 854c461755..6bcc2e3a03 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -22,7 +22,7 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' -import { contextProvenance } from './context-provenance.ts' +import { contextForm, contextProvenance } from './context-provenance.ts' /** * The compaction seam's checkpoint plugin, pinned to the seam's own declaration @@ -62,6 +62,7 @@ function materializeNode( kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, provenance: contextProvenance(event.data.source), + form: contextForm(event.data.source), } } return { diff --git a/packages/client/runtime/tests/context-provenance.spec.ts b/packages/client/runtime/tests/context-provenance.spec.ts index 94d1a12038340e835db2da08095a63ac2e9bc250..e4d11f5fe373966528b815e3f4e16c0f4d126aac 100644 GIT binary patch delta 682 zcmaKp%`OB%6on%pm|0j1Vu4(-=&qAu_KZ=_l|A z5-%XO-oQ${hJ{-_J$@q5%Rc9xbL+bwQ;%@>KD2G@S^eSlzPeKp9p93=$u8;Of~*UYFg&V_?+^SU0kE_L z2Mj@E78{D-!lc;9`28I?!w#SdoI21TxRiq7_!jD3{^4%Fq7JyH1z>@N^PI_SN}!2E zQCtFL#(@K%=UBDQx@7#e=n>Ymi^o}WxtOn1Yd!K$!_)cmK&?{n+0T@U^X2MzU#rcQ zT%E?YYZ)JXvHa_uEWQ7ECVHOLzcCZr;l7$TD3A43kQ7QJwevPh!!ca+(^KA(&-n>R Ci{4lO delta 10 Scmdm@@JC|8 | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null +} + +/** + * Concatenated text of the content blocks, with the non-text blocks kept aside. + * Context is text in practice (every producer injects one text block), but the + * block union is merge-extensible, so an unknown block keeps its own fallback + * rather than vanishing. + */ +function partitionContent(content: ContextMessageNode['content']): { text: string; rest: unknown[] } { + const texts: string[] = [] + const rest: unknown[] = [] + for (const block of content) { + if (block.type === 'text') texts.push(block.text) + else rest.push(block) + } + return { text: texts.join('\n'), rest } +} + +/** The model-facing text, truncated to the display bound. */ +function boundedText(text: string, t: Translate): string { + return text.length > MAX_CHARS + ? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}` + : text +} + +/** One source field rendered as a value row; nested shapes stay compact JSON. */ +function fieldValue(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return JSON.stringify(value) +} + +/** + * Provenance fields as a key/value list. `kind` is omitted because the row + * header already names the producer, and `form` because the presentation the + * reader is looking at IS that value. + */ +function SourceFields({ source }: { source: unknown }): ReactNode { + const record = asRecord(source) + if (record === null) return null + const rows = Object.entries(record).filter(([key]) => key !== 'kind' && key !== 'form') + if (rows.length === 0) return null + return ( +
+ {rows.map(([key, value]) => ( +
+
{key}
+
{fieldValue(value)}
+
+ ))} +
+ ) +} + +/** + * Default presentation: the model-facing text as text, with its real line + * breaks, and the remaining provenance beneath it. This is what every form + * this UI version does not recognize renders as. + * @param props - Durable content, its source, and the locale seat. + * @returns The opaque context body. + */ +export function OpaqueBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + const { text, rest } = partitionContent(content) + return ( + <> + {text !== '' &&
{boundedText(text, t)}
} + {rest.map((block, index) => ( + t('json.truncated', { total })} + /> + ))} + + + ) +} + +/** One reconciled instruction file, as the durable source records it. */ +interface InstructionChange { + action: string + path: string + digest?: string +} + +/** Instruction changes read off the source; empty when the shape is unusable. */ +function instructionChanges(source: unknown): InstructionChange[] { + const record = asRecord(source) + const list = record === null ? undefined : record['changes'] + if (!Array.isArray(list)) return [] + const changes: InstructionChange[] = [] + for (const entry of list) { + const change = asRecord(entry) + if (change === null) continue + const path = change['path'] + if (typeof path !== 'string' || path === '') continue + const action = change['action'] + const digest = change['digest'] + changes.push({ + action: typeof action === 'string' ? action : '', + path, + ...typeof digest === 'string' ? { digest } : {}, + }) + } + return changes +} + +/** + * `instructions` form: the files this context reconciled, then their text. + * + * The text keeps its `` framing verbatim — the framing is part + * of what the model read, so hiding it would misreport the request. + * @param props - Durable content, its source, and the locale seat. + * @returns The instructions context body. + */ +export function InstructionsBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + const changes = instructionChanges(source) + const baseline = asRecord(source)?.['baseline'] === true + const { text, rest } = partitionContent(content) + return ( + <> + {changes.length > 0 && ( +
    + {changes.map(change => ( +
  • + {change.path} + + {t(`message.context.instructions.${change.action === 'remove' ? 'removed' : baseline ? 'loaded' : 'updated'}`)} + +
  • + ))} +
+ )} + {text !== '' &&
{boundedText(text, t)}
} + {rest.map((block, index) => ( + t('json.truncated', { total })} + /> + ))} + + ) +} + +/** One catalog entry, as the durable source records it. */ +interface CatalogEntry { + name: string + description: string +} + +/** Catalog entries read off the source; empty when the shape is unusable. */ +function catalogEntries(source: unknown): CatalogEntry[] { + const record = asRecord(source) + const list = record === null ? undefined : record['entries'] + if (!Array.isArray(list)) return [] + const entries: CatalogEntry[] = [] + for (const item of list) { + const entry = asRecord(item) + if (entry === null) continue + const name = entry['name'] + if (typeof name !== 'string' || name === '') continue + const description = entry['description'] + entries.push({ name, description: typeof description === 'string' ? description : '' }) + } + return entries +} + +/** + * `catalog` form: the published entries as a list, read from the source rather + * than re-parsed out of the model-facing prose. + * + * A catalog whose source carries no usable entries falls through to the opaque + * body, so an older or hand-edited log still shows its text. + * @param props - Durable content, its source, and the locale seat. + * @returns The catalog context body. + */ +export function CatalogBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + const entries = catalogEntries(source) + if (entries.length === 0) return + return ( +
    + {entries.map(entry => ( +
  • + {entry.name} + {entry.description} +
  • + ))} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css index a15d66efc3..3e8a10b658 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css @@ -45,7 +45,6 @@ border-radius: 8px; background: var(--dsw-alias-markdown-code-block); color: var(--dsw-alias-label-tertiary); + /* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */ font: 400 11px/16px var(--ds-font-family-code); - white-space: pre-wrap; - overflow-wrap: anywhere; } diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index 31b2092e6e..7d08391f14 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -1,55 +1,19 @@ -import { useMemo, useState } from 'react' +import { useState } from 'react' import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatViewSlotProps } from '../contract/slots.ts' import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { DisclosureRow } from './DisclosureRow.tsx' +import { CatalogBody, InstructionsBody, OpaqueBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' -const MAX_CHARS = 20_000 - -function inlineJson(payload: unknown): string { - const raw = JSON.stringify(payload) - let formatted = '' - let quoted = false - let escaped = false - - for (let index = 0; index < raw.length; index++) { - const char = raw.charAt(index) - if (quoted) { - formatted += char - if (escaped) escaped = false - else if (char === '\\') escaped = true - else if (char === '"') quoted = false - continue - } - if (char === '"') { - quoted = true - formatted += char - continue - } - if (char === '{' || char === '[') { - formatted += char - const close = char === '{' ? '}' : ']' - if (raw[index + 1] !== close) formatted += ' ' - continue - } - if (char === '}' || char === ']') { - const open = char === '}' ? '{' : '[' - if (raw[index - 1] !== open) formatted += ' ' - formatted += char - continue - } - formatted += char === ':' || char === ',' ? `${char} ` : char - } - return formatted -} - /** Props for the logged non-user message presentation. */ export interface ContextInjectionRowProps { content: ContextMessageNode['content'] source: ContextMessageNode['source'] /** Role and producer name projected from the durable source. */ provenance: ContextMessageNode['provenance'] + /** Producer-declared information form; null renders the opaque body. */ + form: ContextMessageNode['form'] /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -60,18 +24,13 @@ export interface ContextInjectionRowProps { * The header names the role the context plays and, beside it, the producer the * durable source identifies, so a reader can tell an injected skill catalog * from a workspace instruction file or a recalled session without expanding. - * @param props - Durable content, its projected provenance, and the locale seat. - * @returns A collapsed context row with a bounded JSON body. + * The expanded body follows the producer-declared form; an absent or unknown + * form renders the opaque body. + * @param props - Durable content, its projected provenance and form, and the locale seat. + * @returns A collapsed context row with a bounded, form-specific body. */ -export function ContextInjectionRow({ content, source, provenance, t }: ContextInjectionRowProps) { +export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) { const [open, setOpen] = useState(false) - const body = useMemo(() => { - if (!open) return '' - const text = inlineJson({ content, source }) - return text.length > MAX_CHARS - ? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}` - : text - }, [content, open, source, t]) return ( { setOpen(value => !value) }} > -
{body}
+
+ {form === 'instructions' + ? + : form === 'catalog' + ? + : } +
) } diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index b15bd2f987..994cd9ca5c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -257,6 +257,7 @@ export const MessageItem = memo(function MessageItem({ content={node.content} source={node.source} provenance={node.provenance} + form={node.form} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 0043716164..3035856ba8 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -55,6 +55,9 @@ export const zh = { 'message.extraBlock': '附加内容块', 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', + 'message.context.instructions.loaded': '已载入', + 'message.context.instructions.updated': '已更新', + 'message.context.instructions.removed': '已移除', 'message.steering': '插话', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', @@ -170,6 +173,9 @@ export const en = { 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', + 'message.context.instructions.loaded': 'loaded', + 'message.context.instructions.updated': 'updated', + 'message.context.instructions.removed': 'removed', 'message.steering': 'Interjection', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 72a7948bdb..2dc328a052 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -227,14 +227,15 @@ describe('MessageItem arms', () => { expect(fork).toHaveBeenCalledWith(2) }) - it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => { + it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => { const ctxView = render( , ) @@ -245,29 +246,84 @@ describe('MessageItem arms', () => { fireEvent.click(disclosure) expect(disclosure.getAttribute('aria-expanded')).toBe('true') - expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe( - '{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], ' - + '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }', - ) + // An unknown form renders the opaque body: the model-facing text keeps its + // real line breaks instead of being escaped into one JSON line, and the + // remaining provenance follows it as fields. + expect(ctxView.container.querySelector('[data-context-text]')?.textContent) + .toBe('line one\n\nline two') + const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent) + expect(fields).toEqual(['plugin', 'empty', 'list']) fireEvent.keyDown(disclosure, { key: ' ' }) expect(disclosure.getAttribute('aria-expanded')).toBe('false') }) - it('context preserves the bounded JSON truncation contract', () => { + it('the instructions form names the files it reconciled above their text', () => { const view = render( \nInstructions from: AGENTS.md\n
' }], + source: { + kind: 'workspace-instructions', + form: 'instructions', + baseline: true, + changes: [ + { action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' }, + { action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' }, + ], + }, + provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' }, + form: 'instructions', } as never} />, ) - fireEvent.click(view.getByRole('button', { name: '上下文注入' })) - expect(view.container.querySelector('[data-context-injection-body]')?.textContent) - .toMatch(/… 已截断,共 \d+ 字符$/) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md, sub\/AGENTS\.md$/ })) + const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent) + expect(files).toEqual(['AGENTS.md已载入', 'sub/AGENTS.md已移除']) + // The `` framing is part of what the model read, so the + // body keeps it verbatim rather than presenting a cleaned-up excerpt. + expect(view.container.querySelector('[data-context-text]')?.textContent) + .toContain('') + }) + + it('the catalog form lists its durable entries instead of the model-facing prose', () => { + const view = render( + \n\n- `a`: A\n' }], + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill', description: 'Does B' }], + }, + provenance: { role: 'inject', label: 'skill-catalog' }, + form: 'catalog', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent) + expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B']) + expect(view.container.querySelector('[data-context-text]')).toBeNull() + }) + + it('a catalog whose source carries no entries falls back to the opaque body', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.container.querySelector('[data-context-entries]')).toBeNull() + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose') }) it('a recalled session titles its row by role and names the sessions it read', () => { @@ -278,6 +334,7 @@ describe('MessageItem arms', () => { content: [{ type: 'text', text: 'snapshot' }], source: { kind: 'session-reference', version: 1, references: [{ label: '重构 loader' }] }, provenance: { role: 'recall', label: '重构 loader' }, + form: null, } as never} />, ) @@ -293,6 +350,7 @@ describe('MessageItem arms', () => { content: [{ type: 'text', text: 'instructions' }], source: { kind: 'workspace-instructions', changes: [{ path: 'AGENTS.md' }] }, provenance: { role: 'inject', label: 'AGENTS.md' }, + form: null, } as never} />, ) @@ -307,6 +365,7 @@ describe('MessageItem arms', () => { , ) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 3301bf5af6..fef4220777 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -452,6 +452,7 @@ describe('ChatView', () => { const context = { kind: 'context', seq: 4, time: 4_000, content: [], source: null, provenance: { role: 'inject', label: null }, + form: null, } as const satisfies ConversationNode const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true }) const view = render() diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts index 0e63a24a48..2bc230f08c 100644 --- a/packages/client/ui-trajectory/tests/context-branches.spec.ts +++ b/packages/client/ui-trajectory/tests/context-branches.spec.ts @@ -14,6 +14,7 @@ const checkpoint = { content: [], source: { kind: 'plugin', plugin: 'compact' }, provenance: { role: 'inject', label: 'compact' }, + form: null, } as ConversationNode const abandoned = { diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 52dc76070c..71e69eacb3 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -126,6 +126,7 @@ export function apply(ctx: Context, config: Config): void { content: baselineMessage.content, source: { kind: 'workspace-instructions', + form: 'instructions', baseline: true, changes: [...baseline.changes.values()], }, diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 7bb24a43b2..08dbd4f600 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -39,6 +39,8 @@ const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit']) /** Durable provenance and reconciliation facts for one workspace context. */ export interface WorkspaceInstructionSource { kind: 'workspace-instructions' + /** Every workspace context carries instructions read out of a file (the `instructions` context form). */ + form: 'instructions' /** Marks the complete startup/resume baseline rather than a later delta. */ baseline?: true changes: WorkspaceInstructionChange[] @@ -87,7 +89,7 @@ export interface ReconciledInstructionContext { function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): UserMessage { return createUserMessage({ content: [{ type: 'text', text }], - source: { kind: 'workspace-instructions', changes }, + source: { kind: 'workspace-instructions', form: 'instructions', changes }, }) } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 158d96f24d..3f1b9865a9 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -219,6 +219,7 @@ function workspaceChangeContext(scope: string, digest: string): UserMessage { content: [{ type: 'text', text: `instructions for ${scope}` }], source: { kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'set', scope, path: `${scope}/AGENTS.md`, digest }], }, }) @@ -974,6 +975,7 @@ describe('workspace context request injection', () => { role: 'user', source: { kind: 'workspace-instructions', + form: 'instructions', baseline: true, changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }, @@ -1951,6 +1953,7 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' }) expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), @@ -2262,6 +2265,7 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(changed)?.source).toMatchObject({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(changed)?.content)).toBe([ @@ -2487,6 +2491,7 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(removed)?.source).toEqual({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toBe([ @@ -2887,6 +2892,7 @@ describe('dynamic nested workspace context injection', () => { ], source: { kind: 'workspace-instructions', + form: 'instructions', changes: [ null, { action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') }, @@ -3073,6 +3079,7 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' }) expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule') @@ -3505,7 +3512,7 @@ describe('workspace context pending state', () => { // must not mint per-session pending state. expect(commitPendingInstructionContexts(agent, [createUserMessage({ content: [], - source: { kind: 'workspace-instructions', changes: [] }, + source: { kind: 'workspace-instructions', form: 'instructions', changes: [] }, })], pending)).toEqual([]) expect(pending.has(agent.session)).toBe(false) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 73dd125905..1031a77249 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1809,6 +1809,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContentBlockType', declaration: 'export type ContentBlockType = keyof ContentBlockMap;', }, + { + name: 'ContextForm', + declaration: 'export type ContextForm = \'instructions\' | \'catalog\';', + }, + { + name: 'ContextFormed', + declaration: 'export interface ContextFormed {\n readonly form?: ContextForm;\n}', + }, { name: 'ContinuableCreateRequest', declaration: 'export interface ContinuableCreateRequest {\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}', @@ -2135,7 +2143,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'MessageSourceMap', - declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}', + declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}', }, { name: 'ModelMessageSource', diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index bc351e0598..b6da71f594 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -467,9 +467,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect(loadedRequest).toContain('Use the freshly loaded body.') const transcript = handle.agent.session.events.flatMap>((event) => { - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'dsh-tool-skill') { + if (event.type === 'user/message' && event.data.source.kind === 'skill-catalog') { return [{ type: event.type, source: event.data.source, @@ -503,8 +501,14 @@ describe('dsh-agent-spine-demo bundle', () => { }, { "source": { - "kind": "plugin", - "plugin": "dsh-tool-skill", + "entries": [ + { + "description": "Hot-added skill", + "name": "hot-skill", + }, + ], + "form": "catalog", + "kind": "skill-catalog", }, "text": " A skill is a reusable set of task-specific instructions. The following skills are available in this session: diff --git a/packages/llm/llm/src/message.ts b/packages/llm/llm/src/message.ts index 3fa7606c6c..d44a501a60 100644 --- a/packages/llm/llm/src/message.ts +++ b/packages/llm/llm/src/message.ts @@ -29,13 +29,40 @@ export interface ToolMessageSource { callId: CallId } +/** + * What SHAPE of information a producer-supplied context carries, declared by + * the producer beside its provenance. + * + * `MessageSource.kind` answers *who produced this*; `form` answers *what kind + * of thing it is*, and the two axes are deliberately independent — several + * producers share one form (three snapshot producers today), and one producer + * may emit more than one form over a session. + * + * The vocabulary is SEMANTIC, never visual: a value states that the content is + * a file's instructions or a catalog of available items, and a consumer decides + * what that looks like. Colors, icons, ordering, and collapse defaults are the + * consumer's business and must not enter this union. It grows one value at a + * time as producers gain the structured fields their form needs; an absent or + * unknown value is the documented default, presented as opaque content. + */ +export type ContextForm = + /** Instructions read out of workspace files the model is expected to follow. */ + | 'instructions' + /** A catalog of items available in this session, republished as it changes. */ + | 'catalog' + +/** Optional producer-declared {@link ContextForm}, mixed into the source shapes that carry one. */ +export interface ContextFormed { + readonly form?: ContextForm +} + /** * Where a message (or injected content) came from. * Merge-extensible sum type — plugins add their own `kind`s. */ export interface MessageSourceMap { user: { kind: 'user' } - plugin: { kind: 'plugin'; plugin: string } + plugin: { kind: 'plugin'; plugin: string } & ContextFormed model: ModelMessageSource tool: ToolMessageSource } diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 9d706de673..8930f1b6ee 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/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/skill/tool-skill/README.md -README.md: d8e00bc839358f58cd83bfa9b28eed09dd407bce -README.zh.md: 6c0df1d6e38c99ce64cadeb668bbf0ad7b3029e3 +README.md: deccda0ece1ffe2cbdb576a8af3801f28695d8d5 +README.zh.md: 67ec0d32759c5768af32b5a505e7a080e19eb396 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index d8e00bc839..deccda0ece 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -10,7 +10,7 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools At every `agent/step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it injects an initial durable user-role `` before the request. Catalog messages contain only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. -The digest covers the exact rendered text between the `` tags. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest recognizable visible catalog message it sourced. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary. +Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `` framing — written for the model — cannot decide whether a republish is needed, and a consumer presenting the list never re-parses the `` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary. The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 6c0df1d6e3..67ec0d3275 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -10,7 +10,7 @@ 每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将步骤中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 ``。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。 -该 digest 覆盖 `` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 +每条目录消息都携带 `skill-catalog` 来源:一份 `catalog` 形态的上下文,其 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖的是这些持久条目而非渲染出的散文,因此为模型书写的 `` 包装无法左右是否需要重新发布,消费方展示该列表时也不必再解析 `` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见的 `skill-catalog` 消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index efc352fd3f..75762a328c 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -22,9 +22,37 @@ export const name = 'tool-skill' export const inject = ['agents', 'tools', 'skills'] const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500 -const CATALOG_ENTRIES_START = '\n' -const CATALOG_ENTRIES_END = '' -const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const +/** + * Durable provenance for one published session skill catalog. The catalog is a + * `catalog`-form context, so it records the entries it published beside the + * model-facing prose: a consumer presenting the list must not re-parse the + * `` block, whose framing exists for the model. + */ +export interface SkillCatalogSource { + readonly kind: 'skill-catalog' + readonly form: 'catalog' + /** Marks a replacement catalog rather than this session's first publication. */ + readonly update?: true + /** Exactly the entries this message published, in catalog order. */ + readonly entries: readonly { readonly name: string; readonly description: string }[] +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'skill-catalog': SkillCatalogSource + } +} + +/** Durable entry list mirroring the rendered catalog lines, for non-model consumers. */ +function catalogSourceEntries( + skills: SkillSummary[], + descriptionMaxLength: number, +): SkillCatalogSource['entries'] { + return skills.map(skill => ({ + name: skill.name, + description: catalogDescription(skill.description, descriptionMaxLength), + })) +} /** Model-facing skill catalog configuration. */ export interface Config { @@ -142,13 +170,14 @@ export function apply(ctx: Context, config: Config = {}): void { signal.throwIfAborted() if (!snapshot.complete) return const skills = snapshot.skills.filter(isModelInvocable) - const digest = catalogDigest(skills, catalogDescriptionMaxLength) + const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength) + const digest = digestCatalogEntries(entries) const history = catalogHistory(agent) if (history.visibleDigest === digest) return if (!history.published && skills.length === 0) return const catalog = history.published - ? renderCatalogUpdate(skills, catalogDescriptionMaxLength) - : renderCatalogMessage(skills, catalogDescriptionMaxLength) + ? renderCatalogUpdate(entries) + : renderCatalogMessage(entries) agent.inject(catalog) }) } @@ -199,8 +228,7 @@ function renderResourceHint(skill: Pick', - ...entries, + ...renderCatalogEntries(entries), '', '', "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.", '', ].join('\n'), }], - source: PLUGIN_SOURCE, + source: { + kind: 'skill-catalog', + form: 'catalog', + entries, + }, }) } -function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): UserMessage { - const entries = renderCatalogEntries(skills, descriptionMaxLength) - const availability = skills.length === 0 +function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessage { + const availability = entries.length === 0 ? [ 'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.', ] @@ -237,28 +268,36 @@ function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: numbe 'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:', '', '', - ...entries, + ...renderCatalogEntries(entries), '', '', ...availability, '', ].join('\n'), }], - source: PLUGIN_SOURCE, + source: { + kind: 'skill-catalog', + form: 'catalog', + update: true, + entries, + }, }) } -function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] { - return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`) +/** Model-facing catalog lines, projected from the same entries the source records. */ +function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] { + return entries.map(entry => `- \`${entry.name}\`: ${entry.description}`) } -function catalogDigest(skills: SkillSummary[], descriptionMaxLength: number): string { - return digestCatalogEntries(renderCatalogEntries(skills, descriptionMaxLength).join('\n')) -} - -function digestCatalogEntries(entries: string): string { +/** + * Catalog identity over the durable entry list rather than the rendered prose. + * The entries are what changes; the surrounding `` framing is + * written for the model and must not decide whether a republish is needed. + */ +function digestCatalogEntries(entries: SkillCatalogSource['entries']): string { + const canonical = entries.map(entry => `${entry.name}\u0000${entry.description}`).join('\n') return createHash('sha256') - .update(entries) + .update(canonical) .digest('hex') } @@ -270,30 +309,14 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool // The loop bounds prove the read-only event view contains this index. // oxlint-disable-next-line typescript/no-non-null-assertion const event = events[index]! - if (event.type !== 'user/message' - || event.data.source.kind !== 'plugin' - || event.data.source.plugin !== PLUGIN_SOURCE.plugin) continue - const digest = catalogContentDigest(event.data.content) - if (digest === undefined) continue + if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue + const digest = digestCatalogEntries(event.data.source.entries) published = true if (visible.has(event.seq)) return { visibleDigest: digest, published } } return { published } } -function catalogContentDigest(content: UserMessage['content']): string | undefined { - if (content.length !== 1 || content[0]?.type !== 'text') return undefined - const text = content[0].text - const start = text.indexOf(CATALOG_ENTRIES_START) - if (start === -1) return undefined - const entriesStart = start + CATALOG_ENTRIES_START.length - const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart) - if (end === -1) return undefined - const renderedEntries = text.slice(entriesStart, end) - const entries = renderedEntries.endsWith('\n') ? renderedEntries.slice(0, -1) : renderedEntries - return digestCatalogEntries(entries) -} - function catalogDescription(value: string, maxLength: number): string { const normalized = value.replaceAll(/\s+/g, ' ').trim() const truncated = normalized.length <= maxLength diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index df8f4e6fed..b65a890493 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -94,8 +94,7 @@ async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): function catalogMessages(session: Session): Extract[] { return session.events.filter((event): event is Extract => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'dsh-tool-skill') + && event.data.source.kind === 'skill-catalog') } function catalogContent(entries: string[]): Message['content'] { @@ -215,7 +214,15 @@ describe('dsh-tool-skill', () => { { id: expect.any(String) as unknown, role: 'user', - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [ + { name: 'a-skill', description: 'Use {{placeholder}} <safely> & carefully.' }, + { name: 'model-only-skill', description: 'Model-only skill.' }, + { name: 'z-skill', description: 'Long description Long description Long descript...' }, + ], + }, content: [{ type: 'text', text: [ @@ -354,7 +361,12 @@ describe('dsh-tool-skill', () => { expect(catalogMessages(session)).toHaveLength(3) }) - it('resumes from the latest valid visible catalog content', async () => { + it('resumes from the durable entries of the latest visible catalog', async () => { + // Catalog identity moved onto `source.entries` when the catalog became a + // `catalog`-form context: the model-facing prose no longer decides whether + // a republish is needed, so a seeded message is recognized by its source + // alone and malformed prose can no longer hide (or fake) a published + // catalog. A foreign-sourced message is not this plugin's catalog at all. const home = await tempDir('tool-catalog-resume') const ctx = await setup(home) ctx.skills.register({ @@ -367,30 +379,36 @@ describe('dsh-tool-skill', () => { const agent = sessionAgent(session) openMessageTurn(session) session.append('user/message', createUserMessage({ - content: catalogContent(['- `old-skill`: Old skill']), - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + content: [{ type: 'text', text: 'prose a reader cannot rely on' }], + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [{ name: 'old-skill', description: 'Old skill' }], + }, }), { surfaceOp: 'append' }) session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'missing catalog markers' }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: '\nmissing closing marker' }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [{ type: 'reasoning', text: 'not a user-role catalog block' }], + content: catalogContent(['- `resumed-skill`: Resumed skill']), source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, }), { surfaceOp: 'append' }) await fireStep(ctx, agent, 1, 1) - expect(catalogMessages(session)).toHaveLength(6) - expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill') + // The seeded entries differ from the live snapshot, so one replacement + // lands; the foreign-sourced lookalike neither counts as published nor + // suppresses it. + expect(catalogMessages(session)).toHaveLength(2) + const latest = catalogMessages(session).at(-1) + expect(latest?.data.source).toMatchObject({ + kind: 'skill-catalog', + form: 'catalog', + update: true, + entries: [{ name: 'resumed-skill', description: 'Resumed skill' }], + }) + expect(JSON.stringify(latest?.data.content)).toContain('resumed-skill') + + // A second step over unchanged entries republishes nothing. + await fireStep(ctx, agent, 1, 2) + expect(catalogMessages(session)).toHaveLength(2) }) it('re-establishes the current catalog after compaction hides its durable message', async () => { From ebe6ca20ad0eff98c2ea04d9cc9a4eecc6b9f347 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 5 Aug 2026 14:55:21 +0800 Subject: [PATCH 5/9] fix(web): address review findings on producer-declared context forms - `catalogHistory` validated its durable read. `agent.session.events` is a JSONL/SQLite seed on resume or fork, and seed validation guarantees only a source object with a non-empty `kind`; a `skill-catalog` record with missing or wrongly shaped `entries` threw inside the step listener, failing every later turn of that session. It is now skipped as an unrecognizable record, the posture the replaced content digest had, with a regression test over six malformed shapes. - The headless keyless smoke still filtered catalogs by the old plugin source, so the `built-bin-smoke` gate would not have found the catalog message. - Entries record the published description unescaped. The pseudo-XML escaping belongs to the `` frame and is applied when rendering it, so a description containing `<` no longer reaches the card as `<`. `escapeText` is injective, so republish semantics and the model-facing text are unchanged. - Adjacent text blocks join with no separator, matching how provider adapters flatten them; the body no longer shows a line break the model never saw. - Provenance fields are bounded like the text: an unknown producer may record an arbitrarily large value. - Both readers are all-or-nothing, and the row's form marker reports what rendered rather than what was declared, so a partly unreadable record cannot present a confident but incomplete account. - The catalog body consumes `update` as a replacement notice; the digest canonicalizes per entry as JSON, since every separator character is itself legal in a description. - `core.md` documents the form axis with a `ContextForm` type-equiv block, and both projections assert the wiring they duplicate. --- ...26-08-05-context-form-vocabulary.i18n.yaml | 4 +- .../2026-08-05-context-form-vocabulary.md | 10 +- .../2026-08-05-context-form-vocabulary.zh.md | 10 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 26 +++ docs/core-data-structures/core.zh.md | 26 +++ .../headless-agent/tests/keyless-smoke.e2e.ts | 3 +- .../client/runtime/tests/history-fold.spec.ts | 7 +- .../runtime/tests/transcript-adapter.spec.ts | 7 +- .../src/client/chat/ContextBody.module.css | 8 +- .../src/client/chat/ContextBody.tsx | 161 ++++++++++++------ .../src/client/chat/ContextInjectionRow.tsx | 13 +- .../ui-conversation/src/client/locales.ts | 2 + .../tests/chat-branch-tails.spec.tsx | 102 +++++++++++ packages/skill/tool-skill/src/index.ts | 47 ++++- .../skill/tool-skill/tests/tool-skill.spec.ts | 52 +++++- scripts/type-equiv.manifest.json | 5 + 17 files changed, 408 insertions(+), 79 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml index aeba2cd585..ffde4bbd2d 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.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 .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md -2026-08-05-context-form-vocabulary.md: c2369a21a474e677eb324b6f8d2457091ee44e30 -2026-08-05-context-form-vocabulary.zh.md: 3c16279a4e36742eb7c5d60aa1c5ad9a2ae07cd6 +2026-08-05-context-form-vocabulary.md: 20577b0ad0eaa9eb8dc8528cedd18d15439ad299 +2026-08-05-context-form-vocabulary.zh.md: 154e8ae54fc6b27ce782533b4246d48190639128 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md index c2369a21a4..20577b0ad0 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md @@ -23,10 +23,16 @@ The vocabulary is semantic, never visual. A value states that the content is a f **`instructions`** — instructions read out of workspace files. `workspace-context` declares it on both the startup baseline and later deltas; its existing `changes[]` already carried the paths, actions, and digests the presentation needs, so no field was added. The body lists the reconciled files above the text, and keeps the `` framing verbatim: the framing is part of what the model read, so hiding it would misreport the request. -**`catalog`** — a catalog of items available this session, republished as it changes. `dsh-tool-skill` moves off the shared `plugin` kind to its own `skill-catalog` source carrying `entries` (the exact `name`/`description` pairs published) and `update` on a replacement. The body lists those entries instead of re-parsing the `` block out of the prose. +**`catalog`** — a catalog of items available this session, republished as it changes. `dsh-tool-skill` moves off the shared `plugin` kind to its own `skill-catalog` source carrying `entries` (the exact `name`/`description` pairs published) and `update` on a replacement, which the body renders as a replacement notice. The body lists those entries instead of re-parsing the `` block out of the prose. + +Entries record the published fact **unescaped**. The pseudo-XML escaping belongs to the `` frame, which exists for the model, so it is applied when rendering that frame and never stored; otherwise a consumer would have to know the frame's encoding to display a description containing `<`, and the same frame knowledge this decision removes would leak back in another shape. `escapeText` is deterministic and injective, so digesting the unescaped entries preserves republish semantics exactly, and the model-facing text stays byte-identical. That move also relocates catalog **identity**: the republish digest now covers the durable entries rather than the rendered text, so the model-facing framing can no longer decide whether a republish is needed, and the text-slicing that recovered entries from a logged message is gone. A resumed session whose newest catalog predates this change republishes once, which the pre-release stance permits. +Both readers are **all-or-nothing**: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared. + +The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.events`, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point. + Everything else — including a form this UI version does not present, a form absent from the source, and a `catalog` whose entries are unusable — renders the **opaque** body: the model-facing text with its real line breaks, then the remaining provenance as fields. Opaque is the documented default, not a leftover bin. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer. ## Why not a presenter registry @@ -47,7 +53,7 @@ The tool seam pairs its vocabulary with `presentCall(args)`, a host-side pure fu - `packages/client/runtime` pins the form projection, including the unknown, empty, wrongly-typed, and absent values that must degrade to opaque. - `packages/client/ui-conversation` pins each body: the opaque body's preserved line breaks and provenance fields, the instructions body's file list and verbatim framing, the catalog body's entry list, and a catalog with unusable entries falling back to opaque. -- `packages/skill/tool-skill` pins the new source on first publication and replacement, and republish behavior driven by the durable entries. +- `packages/skill/tool-skill` pins the new source on first publication and replacement, republish behavior driven by the durable entries, and a malformed durable catalog leaving step observation intact. - The keyless assembled-Web seeded-history scenario expands a real `instructions` context in Chromium and asserts its file list, verbatim framing, and the unchanged disclosure geometry. `catalog` has no assembled coverage: the hermetic scaffold publishes no skills, so no catalog reaches a browser scenario. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md index 3c16279a4e..154e8ae54f 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md @@ -23,10 +23,16 @@ Status: implemented **`instructions`**——从工作区文件中读出的指令。`workspace-context` 在启动基线与后续增量上都声明它;其既有的 `changes[]` 已经携带了呈现所需的路径、动作与 digest,因此没有新增字段。内容区在正文之上列出对账过的文件,并原样保留 `` 包装:那层包装本就是模型读到的一部分,隐藏它会歪曲这次请求。 -**`catalog`**——本会话可用项的目录,随变化重新发布。`dsh-tool-skill` 从共享的 `plugin` kind 迁到自有的 `skill-catalog` 来源,携带 `entries`(本次发布的 `name`/`description` 对)与替换目录上的 `update`。内容区直接列出这些条目,不再从散文里反解 `` 块。 +**`catalog`**——本会话可用项的目录,随变化重新发布。`dsh-tool-skill` 从共享的 `plugin` kind 迁到自有的 `skill-catalog` 来源,携带 `entries`(本次发布的 `name`/`description` 对)与替换目录上的 `update`,后者由内容区渲染成替换提示。内容区直接列出这些条目,不再从散文里反解 `` 块。 + +条目记录的是**未转义**的发布事实。伪 XML 转义属于 `` 这层为模型而设的框架,因此只在渲染该框架时施加、从不存储;否则消费方要正确展示含 `<` 的描述就得知道框架的编码方式,本决策刚移除的框架知识会换一种形式泄漏回来。`escapeText` 确定且单射,故对未转义条目取 digest 与此前完全等价,重新发布语义不变,面向模型的文本逐字节不变。 这次迁移同时挪动了目录的**身份**:重新发布用的 digest 现在覆盖持久条目而非渲染文本,于是面向模型的包装再也无法左右是否需要重新发布,那段从已记录消息里切出条目的文本切分逻辑也随之删除。若恢复的会话中最新目录早于本次改动,会重新发布一次——发布前阶段的姿态允许这样做。 +两个读取器都是**全有或全无**:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。 + +生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.events` 读 `source.entries`,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。 + 其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源信息列成字段。opaque 是有文档的默认,不是兜底垃圾桶。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。 ## 为什么不做 presenter 注册表 @@ -47,7 +53,7 @@ Status: implemented - `packages/client/runtime` 钉住形态投影,包括必须降级为 opaque 的未知值、空值、类型不符与缺失。 - `packages/client/ui-conversation` 逐个钉住内容区:opaque 的换行留存与来源字段、instructions 的文件列表与原样包装、catalog 的条目列表,以及条目不可用的 catalog 回落到 opaque。 -- `packages/skill/tool-skill` 钉住首次发布与替换时的新来源,以及由持久条目驱动的重新发布行为。 +- `packages/skill/tool-skill` 钉住首次发布与替换时的新来源、由持久条目驱动的重新发布行为,以及畸形持久目录不打断步骤观察。 - 无密钥的组装 Web seeded-history 场景在 Chromium 中展开一条真实的 `instructions` 上下文,断言其文件列表、原样包装与未改动的展开项几何。`catalog` 没有组装态覆盖:隔离脚手架不发布任何 skill,因此没有目录能进入浏览器场景。 ## Consequences diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index b45a0ce588..4d3c96d424 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 docs/core-data-structures/core.md -core.md: 6c12b8af07498590bb8eb5b92c3c8f6bbef313f3 -core.zh.md: f83e8873adfae5ab81c02678605f4014214b96b6 +core.md: d91e84a7066b004567ed9f668ded52ee486a1c99 +core.zh.md: f9cf2c14fe1021d7862d8bb14696044b7d8f7fbb diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6c12b8af07..d91e84a706 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -167,6 +167,32 @@ interface MessageSourceMap { } ``` +Provenance and shape are two independent axes. `kind` answers *who produced this*; the optional `form` a producer mixes in answers *what shape of information it is*, so several producers may share one presentation and one producer may emit more than one shape over a session. The vocabulary is semantic and grows one value at a time; an absent or unrecognized value is the documented default, presented as opaque content: + +```ts type-equiv +/** + * What SHAPE of information a producer-supplied context carries, declared by + * the producer beside its provenance. + * + * `MessageSource.kind` answers *who produced this*; `form` answers *what kind + * of thing it is*, and the two axes are deliberately independent — several + * producers share one form (three snapshot producers today), and one producer + * may emit more than one form over a session. + * + * The vocabulary is SEMANTIC, never visual: a value states that the content is + * a file's instructions or a catalog of available items, and a consumer decides + * what that looks like. Colors, icons, ordering, and collapse defaults are the + * consumer's business and must not enter this union. It grows one value at a + * time as producers gain the structured fields their form needs; an absent or + * unknown value is the documented default, presented as opaque content. + */ +type ContextForm = + /** Instructions read out of workspace files the model is expected to follow. */ + | 'instructions' + /** A catalog of items available in this session, republished as it changes. */ + | 'catalog' +``` + ## Streaming Adapters emit a raw **chunk** protocol; the loop logs the chunks (replay fidelity) while feeding the same chunks through a `BlockAssembler` to rebuild blocks and messages. `StreamChunk` is a closed discriminated union over `type` — `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index f83e8873ad..f9cf2c14fe 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -173,6 +173,32 @@ interface MessageSourceMap { } ``` +溯源与形态是相互独立的两根轴。`kind` 回答「由谁产生」;生产方可选混入的 `form` 回答「这是何种形态的信息」,因此多个生产方可以共用一种呈现,一个生产方在一次会话中也可以发出多种形态。该词汇表是语义的,逐个取值增长;未声明或无法识别的取值是有文档的默认,按不透明内容呈现: + +```ts type-equiv +/** + * What SHAPE of information a producer-supplied context carries, declared by + * the producer beside its provenance. + * + * `MessageSource.kind` answers *who produced this*; `form` answers *what kind + * of thing it is*, and the two axes are deliberately independent — several + * producers share one form (three snapshot producers today), and one producer + * may emit more than one form over a session. + * + * The vocabulary is SEMANTIC, never visual: a value states that the content is + * a file's instructions or a catalog of available items, and a consumer decides + * what that looks like. Colors, icons, ordering, and collapse defaults are the + * consumer's business and must not enter this union. It grows one value at a + * time as producers gain the structured fields their form needs; an absent or + * unknown value is the documented default, presented as opaque content. + */ +type ContextForm = + /** Instructions read out of workspace files the model is expected to follow. */ + | 'instructions' + /** A catalog of items available in this session, republished as it changes. */ + | 'catalog' +``` + ## 流式输出 适配器发出原始**分片**协议;循环记录分片(回放保真度),同时将同一批分片送入 `BlockAssembler` 以重建块和消息。`StreamChunk` 是基于 `type` 的封闭判别联合——`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`。 diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index 4e18e177c3..29afe3cb3f 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -39,8 +39,7 @@ describe('headless-agent keyless smoke', () => { expect(stderr).toBe('') expect(events.some(event => event.type === 'tool/call' && event.data.name === 'bash')).toBe(true) const catalogMessage = events.find(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'dsh-tool-skill') + && event.data.source.kind === 'skill-catalog') const catalog = catalogMessage?.type === 'user/message' ? catalogMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('\n') : '' diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts index 527e324e26..221c9705e1 100644 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ b/packages/client/runtime/tests/history-fold.spec.ts @@ -19,12 +19,15 @@ describe('projectConversationHistory', () => { // A plugin source, because the client program does not see the host // packages that merge richer source kinds; those arms are pinned in // context-provenance.spec.ts. - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' }, }), }) const { contexts } = projectConversationHistory([{ event: injected }]) expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{ - kind: 'context', seq: 0, provenance: { role: 'inject', label: 'dsh-tool-skill' }, + kind: 'context', + seq: 0, + provenance: { role: 'inject', label: 'dsh-tool-skill' }, + form: 'catalog', }]) }) diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index 9aa2654554..789fb4c992 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -205,11 +205,14 @@ describe('TranscriptAdapter', () => { adapter.reset([ at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ content: [{ type: 'text', text: '注入的上下文' }], - source: { kind: 'plugin', plugin: 'compact' }, + source: { kind: 'plugin', plugin: 'compact', form: 'instructions' }, }) }), ]) expect(adapter.nodes()).toMatchObject([{ - kind: 'context', seq: 0, provenance: { role: 'inject', label: 'compact' }, + kind: 'context', + seq: 0, + provenance: { role: 'inject', label: 'compact' }, + form: 'instructions', }]) }) diff --git a/packages/client/ui-conversation/src/client/chat/ContextBody.module.css b/packages/client/ui-conversation/src/client/chat/ContextBody.module.css index 251bb83c0b..d204943fea 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextBody.module.css +++ b/packages/client/ui-conversation/src/client/chat/ContextBody.module.css @@ -65,7 +65,13 @@ color: var(--dsw-alias-label-caption); } -/* catalog: one row per published entry. */ +/* catalog: a replacement notice above one row per published entry. */ +.catalogNotice { + margin: 0 0 6px; + color: var(--dsw-alias-label-caption); +} + + .entries { display: flex; flex-direction: column; diff --git a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx index 845097d763..d590d104b5 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx @@ -5,7 +5,7 @@ // even when this UI version has never seen its producer. import type { ReactNode } from 'react' -import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { ContextMessageNode, KnownContextForm } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import css from './ContextBody.module.css' @@ -27,6 +27,9 @@ function asRecord(value: unknown): Record | null { * Context is text in practice (every producer injects one text block), but the * block union is merge-extensible, so an unknown block keeps its own fallback * rather than vanishing. + * + * Blocks join with no separator, matching how provider adapters flatten them: + * inserting a line break here would show the reader a line the model never saw. */ function partitionContent(content: ContextMessageNode['content']): { text: string; rest: unknown[] } { const texts: string[] = [] @@ -35,7 +38,7 @@ function partitionContent(content: ContextMessageNode['content']): { text: strin if (block.type === 'text') texts.push(block.text) else rest.push(block) } - return { text: texts.join('\n'), rest } + return { text: texts.join(''), rest } } /** The model-facing text, truncated to the display bound. */ @@ -45,11 +48,16 @@ function boundedText(text: string, t: Translate): string { : text } -/** One source field rendered as a value row; nested shapes stay compact JSON. */ -function fieldValue(value: unknown): string { - if (typeof value === 'string') return value - if (typeof value === 'number' || typeof value === 'boolean') return String(value) - return JSON.stringify(value) +/** + * One source field rendered as a value row; nested shapes stay compact JSON. + * Bounded on its own, because provenance is as unbounded as the text: an unknown + * producer may record an arbitrarily large string or array. + */ +function fieldValue(value: unknown, t: Translate): string { + const text = typeof value === 'string' + ? value + : typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value) + return boundedText(text, t) } /** @@ -57,7 +65,7 @@ function fieldValue(value: unknown): string { * header already names the producer, and `form` because the presentation the * reader is looking at IS that value. */ -function SourceFields({ source }: { source: unknown }): ReactNode { +function SourceFields({ source, t }: { source: unknown; t: Translate }): ReactNode { const record = asRecord(source) if (record === null) return null const rows = Object.entries(record).filter(([key]) => key !== 'kind' && key !== 'form') @@ -67,7 +75,7 @@ function SourceFields({ source }: { source: unknown }): ReactNode { {rows.map(([key, value]) => (
{key}
-
{fieldValue(value)}
+
{fieldValue(value, t)}
))} @@ -98,7 +106,7 @@ export function OpaqueBody({ content, source, t }: { truncatedLabel={total => t('json.truncated', { total })} /> ))} - + ) } @@ -110,26 +118,37 @@ interface InstructionChange { digest?: string } -/** Instruction changes read off the source; empty when the shape is unusable. */ -function instructionChanges(source: unknown): InstructionChange[] { +/** + * Instruction changes read off the source, or null when the record is not a + * usable instruction list. + * + * The read is all-or-nothing: silently dropping one unreadable entry would show + * a confident, incomplete file list for a log this version cannot fully read. + * Paths are deduplicated in first-seen order, matching how the header label is + * derived from the same array. + */ +function instructionChanges(source: unknown): InstructionChange[] | null { const record = asRecord(source) const list = record === null ? undefined : record['changes'] - if (!Array.isArray(list)) return [] + if (!Array.isArray(list)) return null const changes: InstructionChange[] = [] - for (const entry of list) { + const seen = new Set() + for (const entry of list as readonly unknown[]) { const change = asRecord(entry) - if (change === null) continue + if (change === null) return null const path = change['path'] - if (typeof path !== 'string' || path === '') continue + if (typeof path !== 'string' || path === '') return null const action = change['action'] const digest = change['digest'] + if (seen.has(path)) continue + seen.add(path) changes.push({ action: typeof action === 'string' ? action : '', path, ...typeof digest === 'string' ? { digest } : {}, }) } - return changes + return changes.length === 0 ? null : changes } /** @@ -138,7 +157,8 @@ function instructionChanges(source: unknown): InstructionChange[] { * The text keeps its `` framing verbatim — the framing is part * of what the model read, so hiding it would misreport the request. * @param props - Durable content, its source, and the locale seat. - * @returns The instructions context body. + * @returns The instructions context body, or the opaque body when the change + * list is unreadable. */ export function InstructionsBody({ content, source, t }: { content: ContextMessageNode['content'] @@ -146,22 +166,21 @@ export function InstructionsBody({ content, source, t }: { t: Translate }): ReactNode { const changes = instructionChanges(source) + if (changes === null) return const baseline = asRecord(source)?.['baseline'] === true const { text, rest } = partitionContent(content) return ( <> - {changes.length > 0 && ( -
    - {changes.map(change => ( -
  • - {change.path} - - {t(`message.context.instructions.${change.action === 'remove' ? 'removed' : baseline ? 'loaded' : 'updated'}`)} - -
  • - ))} -
- )} +
    + {changes.map(change => ( +
  • + {change.path} + + {t(`message.context.instructions.${change.action === 'remove' ? 'removed' : baseline ? 'loaded' : 'updated'}`)} + +
  • + ))} +
{text !== '' &&
{boundedText(text, t)}
} {rest.map((block, index) => ( + if (entries === null) return + const update = asRecord(source)?.['update'] === true return ( -
    - {entries.map(entry => ( -
  • - {entry.name} - {entry.description} -
  • - ))} -
+ <> + {update &&

{t('message.context.catalog.replaced')}

} +
    + {entries.map((entry, index) => ( + // Index key: a hand-edited or foreign log may repeat a name, and a + // duplicate React key would drop a row the model did see. +
  • + {entry.name} + {entry.description} +
  • + ))} +
+ ) } + +/** + * Choose the body for one context node. + * + * Returns the form the body actually rendered as, which is not always the + * declared one: a declared form whose fields are unreadable falls back to + * opaque, and the caller labels the row with what it really shows. + * @param form - the producer-declared form projected onto the node. + * @param props - durable content, its source, and the locale seat. + * @returns the rendered form (null for opaque) and its body. + */ +export function contextBody( + form: ContextMessageNode['form'], + props: { content: ContextMessageNode['content']; source: unknown; t: Translate }, +): { rendered: KnownContextForm | null; body: ReactNode } { + switch (form) { + case 'instructions': + return instructionChanges(props.source) === null + ? { rendered: null, body: } + : { rendered: 'instructions', body: } + case 'catalog': + return catalogEntries(props.source) === null + ? { rendered: null, body: } + : { rendered: 'catalog', body: } + case null: + return { rendered: null, body: } + /* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new + KnownContextForm here rather than letting it degrade to opaque silently. */ + default: { + const unreachable: never = form + throw new Error(`unreachable context form: ${String(unreachable)}`) + } + } +} diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index 7d08391f14..2c561c96de 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -3,7 +3,7 @@ import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatViewSlotProps } from '../contract/slots.ts' import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { DisclosureRow } from './DisclosureRow.tsx' -import { CatalogBody, InstructionsBody, OpaqueBody } from './ContextBody.tsx' +import { contextBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' /** Props for the logged non-user message presentation. */ @@ -31,6 +31,9 @@ export interface ContextInjectionRowProps { */ export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) { const [open, setOpen] = useState(false) + // Resolved rather than declared: a form whose fields are unreadable renders + // the opaque body, and the marker must say what the row actually shows. + const { rendered, body } = contextBody(form, { content, source, t }) return ( { setOpen(value => !value) }} > -
- {form === 'instructions' - ? - : form === 'catalog' - ? - : } +
+ {body}
) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 3035856ba8..70673c145e 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -58,6 +58,7 @@ export const zh = { 'message.context.instructions.loaded': '已载入', 'message.context.instructions.updated': '已更新', 'message.context.instructions.removed': '已移除', + 'message.context.catalog.replaced': '替换目录', 'message.steering': '插话', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', @@ -176,6 +177,7 @@ export const en = { 'message.context.instructions.loaded': 'loaded', 'message.context.instructions.updated': 'updated', 'message.context.instructions.removed': 'removed', + 'message.context.catalog.replaced': 'Replacement catalog', 'message.steering': 'Interjection', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 2dc328a052..c5569fa4f1 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -271,6 +271,7 @@ describe('MessageItem arms', () => { changes: [ { action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' }, { action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' }, + { action: 'replace', scope: '.\u0000AGENTS.md', path: 'AGENTS.md' }, ], }, provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' }, @@ -307,6 +308,107 @@ describe('MessageItem arms', () => { const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent) expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B']) expect(view.container.querySelector('[data-context-text]')).toBeNull() + expect(view.container.querySelector('[data-context-catalog-update]')).toBeNull() + }) + + it('a replacement catalog says so above its entries', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录') + }) + + it('a partially unreadable catalog falls back whole rather than showing a short list', () => { + // All-or-nothing: a body that replaces the model-facing text must not show + // a confident, incomplete account of what the model read. + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.container.querySelector('[data-context-entries]')).toBeNull() + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose') + // The marker reports what rendered, not what was declared. + expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form')) + .toBeNull() + }) + + it('an unreadable instruction list falls back to the opaque body with its fields', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ })) + expect(view.container.querySelector('[data-context-files]')).toBeNull() + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose') + expect(view.container.querySelector('[data-context-fields]')).not.toBeNull() + }) + + it('joins adjacent text blocks the way a provider adapter flattens them', () => { + // No invented separator: showing a line break the model never saw would + // misreport the request. + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: '上下文注入' })) + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond') + }) + + it('bounds an oversized provenance field, not only the model-facing text', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ })) + expect(view.container.querySelector('[data-context-fields] dd')?.textContent) + .toMatch(/… 已截断,共 \d+ 字符$/) }) it('a catalog whose source carries no entries falls back to the opaque body', () => { diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 75762a328c..0435ef92a1 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -284,9 +284,14 @@ function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessag }) } -/** Model-facing catalog lines, projected from the same entries the source records. */ +/** + * Model-facing catalog lines, projected from the same entries the source records. + * The pseudo-XML escaping belongs to this frame, not to the published fact, so it + * is applied here and never stored. Names are `isSkillName`-validated and carry + * no escapable character. + */ function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] { - return entries.map(entry => `- \`${entry.name}\`: ${entry.description}`) + return entries.map(entry => `- \`${entry.name}\`: ${escapeText(entry.description)}`) } /** @@ -295,12 +300,38 @@ function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] * written for the model and must not decide whether a republish is needed. */ function digestCatalogEntries(entries: SkillCatalogSource['entries']): string { - const canonical = entries.map(entry => `${entry.name}\u0000${entry.description}`).join('\n') + // JSON per entry rather than a separator character: every separator is itself + // a legal description character, so only quoting makes the boundary exact. + const canonical = entries.map(entry => JSON.stringify([entry.name, entry.description])).join('\n') return createHash('sha256') .update(canonical) .digest('hex') } +/** + * Entries of one durable catalog message, or undefined when the record is not a + * usable catalog. + * + * `agent.session.events` may be a resumed, forked, or externally written seed, + * and seed validation only guarantees a source object with a non-empty `kind`; + * no per-kind field is checked there. An unreadable record is therefore treated + * as "not this plugin's catalog" — the posture the replaced content digest had — + * rather than throwing inside the step listener, which would fail every + * subsequent turn of that session. + */ +function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | undefined { + const entries = (source as { entries?: unknown }).entries + if (!Array.isArray(entries)) return undefined + const readable: { name: string; description: string }[] = [] + for (const entry of entries as readonly unknown[]) { + if (typeof entry !== 'object' || entry === null) return undefined + const { name, description } = entry as { name?: unknown; description?: unknown } + if (typeof name !== 'string' || name === '' || typeof description !== 'string') return undefined + readable.push({ name, description }) + } + return readable +} + function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } { const visible = new Set(agent.session.surface.nodes) const events = agent.session.events @@ -310,19 +341,19 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool // oxlint-disable-next-line typescript/no-non-null-assertion const event = events[index]! if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue - const digest = digestCatalogEntries(event.data.source.entries) + const entries = readCatalogEntries(event.data.source) + if (entries === undefined) continue + const digest = digestCatalogEntries(entries) published = true if (visible.has(event.seq)) return { visibleDigest: digest, published } } return { published } } +/** Normalized, length-bounded description exactly as the catalog publishes it (unescaped). */ function catalogDescription(value: string, maxLength: number): string { const normalized = value.replaceAll(/\s+/g, ' ').trim() - const truncated = normalized.length <= maxLength - ? normalized - : `${normalized.slice(0, maxLength - 3)}...` - return escapeText(truncated) + return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...` } function assertPositiveInteger(name: string, value: number, minimum = 1): void { diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 56a039ebc0..b2729ddc9e 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -97,6 +97,14 @@ function catalogMessages(session: Session): Extract): boolean { + const entries = (event.data.source as { entries?: unknown }).entries + return Array.isArray(entries) + && entries.every(entry => typeof entry === 'object' && entry !== null + && typeof (entry as { name?: unknown }).name === 'string' + && typeof (entry as { description?: unknown }).description === 'string') +} + function catalogContent(entries: string[]): Message['content'] { return [{ type: 'text', @@ -218,7 +226,7 @@ describe('dsh-tool-skill', () => { kind: 'skill-catalog', form: 'catalog', entries: [ - { name: 'a-skill', description: 'Use {{placeholder}} <safely> & carefully.' }, + { name: 'a-skill', description: 'Use {{placeholder}} & carefully.' }, { name: 'model-only-skill', description: 'Model-only skill.' }, { name: 'z-skill', description: 'Long description Long description Long descript...' }, ], @@ -411,6 +419,48 @@ describe('dsh-tool-skill', () => { expect(catalogMessages(session)).toHaveLength(2) }) + it('treats a malformed durable catalog as unrecognizable instead of failing the step', async () => { + // Seeds reach `agent.session.events` from JSONL/SQLite on resume or fork, + // and seed validation only guarantees a source object with a non-empty + // `kind`. A catalog whose entries are missing or wrongly shaped must be + // skipped like any foreign record; throwing here would fail every later + // step of that session at the latest possible point. + const home = await tempDir('tool-catalog-malformed') + const ctx = await setup(home) + ctx.skills.register({ + name: 'live-skill', + description: 'Live skill', + source: 'runtime', + content: 'Live body.', + }) + const session = Session.create(SessionId('catalog-malformed')) + const agent = sessionAgent(session) + openMessageTurn(session) + for (const source of [ + { kind: 'skill-catalog', form: 'catalog' }, + { kind: 'skill-catalog', form: 'catalog', entries: null }, + { kind: 'skill-catalog', form: 'catalog', entries: 'not-an-array' }, + { kind: 'skill-catalog', form: 'catalog', entries: [null] }, + { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'x' }] }, + { kind: 'skill-catalog', form: 'catalog', entries: [{ description: 'no name' }] }, + ]) { + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'unreadable catalog' }], + source: source as never, + }), { surfaceOp: 'append' }) + } + + await expect(fireStep(ctx, agent, 1, 1)).resolves.toBeUndefined() + + // None of the six counted as published, so the live catalog lands as a + // first publication rather than a replacement. + const published = catalogMessages(session).filter(event => readableCatalog(event)) + expect(published).toHaveLength(1) + expect(published[0]?.data.source).toMatchObject({ kind: 'skill-catalog', form: 'catalog' }) + expect(published[0]?.data.source).not.toHaveProperty('update') + expect(JSON.stringify(published[0]?.data.content)).toContain('live-skill') + }) + it('re-establishes the current catalog after compaction hides its durable message', async () => { const home = await tempDir('tool-catalog-compaction') const ctx = await setup(home) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f8e56eab8d..5fd940e962 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -26,6 +26,11 @@ "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/message.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContextForm", + "source": "packages/llm/llm/src/message.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", From aeb718688e81cc38b3032d34d7476587a74c5df0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 5 Aug 2026 15:15:47 +0800 Subject: [PATCH 6/9] refactor(web): share the model-facing content body across context forms The opaque and instructions bodies rendered the same text-plus-unknown-block pair, which the duplication gate flagged as a clone. Both now use one ModelFacingContent component; the CI lint gate (lint + duplication) passes. --- .../src/client/chat/ContextBody.tsx | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx index d590d104b5..2978fd9400 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx @@ -83,15 +83,14 @@ function SourceFields({ source, t }: { source: unknown; t: Translate }): ReactNo } /** - * Default presentation: the model-facing text as text, with its real line - * breaks, and the remaining provenance beneath it. This is what every form - * this UI version does not recognize renders as. - * @param props - Durable content, its source, and the locale seat. - * @returns The opaque context body. + * The model-facing content of one context, shared by every form that shows it: + * the text with its real line breaks, then any block this UI version does not + * know, which keeps its own fallback rather than vanishing. + * @param props - Durable content and the locale seat. + * @returns The content blocks as the model received them. */ -export function OpaqueBody({ content, source, t }: { +function ModelFacingContent({ content, t }: { content: ContextMessageNode['content'] - source: unknown t: Translate }): ReactNode { const { text, rest } = partitionContent(content) @@ -106,6 +105,25 @@ export function OpaqueBody({ content, source, t }: { truncatedLabel={total => t('json.truncated', { total })} /> ))} + + ) +} + +/** + * Default presentation: the model-facing text as text, with its real line + * breaks, and the remaining provenance beneath it. This is what every form + * this UI version does not recognize renders as. + * @param props - Durable content, its source, and the locale seat. + * @returns The opaque context body. + */ +export function OpaqueBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + return ( + <> + ) @@ -168,7 +186,6 @@ export function InstructionsBody({ content, source, t }: { const changes = instructionChanges(source) if (changes === null) return const baseline = asRecord(source)?.['baseline'] === true - const { text, rest } = partitionContent(content) return ( <>
    @@ -181,15 +198,7 @@ export function InstructionsBody({ content, source, t }: { ))}
- {text !== '' &&
{boundedText(text, t)}
} - {rest.map((block, index) => ( - t('json.truncated', { total })} - /> - ))} + ) } From ccd27f3775e13743125fbb9cb32db1f0005cfef9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 5 Aug 2026 16:51:01 +0800 Subject: [PATCH 7/9] fix(web): address the second review round on context provenance - An empty replacement catalog is a real catalog: `renderCatalogUpdate()` publishes zero entries when the last skill disappears, and falling back would hide that every earlier name was retired. - The opaque fallback keeps a `form` declaration this version cannot present. It is the one place a newer or foreign log's declared shape would otherwise vanish from the UI entirely, since the row marker is also absent there. - An instruction change with an unrecognized `action` disqualifies the record. The action decides the word the row shows, so an unknown one would be presented as loaded or updated. - The catalog list bounds itself and reports the withheld count. Entry count is unbounded and the scrollport bounds height, not node count. - A catalog message keeps content blocks this version does not know, instead of dropping model-visible content the extensible union may carry. - `core.md` defines `ContextFormed`, the interface actually carrying the optional field, beside `ContextForm`. - The superseded-in-part bullet states the affected clauses as one rule rather than enumerating them; two rounds of enumeration each missed some, which is the shape being fragile rather than the list being wrong. - The note records the one migration case that does not self-heal: an old-format catalog as the only one, with an empty current view, leaves a stale catalog nothing replaces. --- ...26-08-05-context-form-vocabulary.i18n.yaml | 4 +- .../2026-08-05-context-form-vocabulary.md | 2 +- .../2026-08-05-context-form-vocabulary.zh.md | 2 +- ...eer-entry-or-interjection-chrome.i18n.yaml | 4 +- ...i-no-steer-entry-or-interjection-chrome.md | 2 +- ...o-steer-entry-or-interjection-chrome.zh.md | 2 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 7 ++ docs/core-data-structures/core.zh.md | 7 ++ .../src/client/chat/ContextBody.tsx | 83 +++++++++++++----- .../ui-conversation/src/client/locales.ts | 2 + .../tests/chat-branch-tails.spec.tsx | 87 ++++++++++++++----- packages/skill/tool-skill/README.i18n.yaml | 4 +- packages/skill/tool-skill/README.md | 2 +- packages/skill/tool-skill/README.zh.md | 2 +- scripts/type-equiv.manifest.json | 5 ++ 16 files changed, 160 insertions(+), 59 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml index ffde4bbd2d..b8a21b4fcb 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.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 .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md -2026-08-05-context-form-vocabulary.md: 20577b0ad0eaa9eb8dc8528cedd18d15439ad299 -2026-08-05-context-form-vocabulary.zh.md: 154e8ae54fc6b27ce782533b4246d48190639128 +2026-08-05-context-form-vocabulary.md: c111acfec4fa85fe9e25adeb615371d65098f20f +2026-08-05-context-form-vocabulary.zh.md: d4da218c4c6026eb875513c2acbfcd18ced8faa1 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md index 20577b0ad0..c111acfec4 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md @@ -27,7 +27,7 @@ The vocabulary is semantic, never visual. A value states that the content is a f Entries record the published fact **unescaped**. The pseudo-XML escaping belongs to the `` frame, which exists for the model, so it is applied when rendering that frame and never stored; otherwise a consumer would have to know the frame's encoding to display a description containing `<`, and the same frame knowledge this decision removes would leak back in another shape. `escapeText` is deterministic and injective, so digesting the unescaped entries preserves republish semantics exactly, and the model-facing text stays byte-identical. -That move also relocates catalog **identity**: the republish digest now covers the durable entries rather than the rendered text, so the model-facing framing can no longer decide whether a republish is needed, and the text-slicing that recovered entries from a logged message is gone. A resumed session whose newest catalog predates this change republishes once, which the pre-release stance permits. +That move also relocates catalog **identity**: the republish digest now covers the durable entries rather than the rendered text, so the model-facing framing can no longer decide whether a republish is needed, and the text-slicing that recovered entries from a logged message is gone. A resumed session whose newest catalog predates this change republishes once, which the pre-release stance permits. One case does not self-heal: if that old-format catalog is the only one and the current view has no skills, the plugin sees no published catalog and emits no tombstone, so the model keeps a stale catalog nothing replaces. The pre-release stance ("backends reject old on-disk formats") permits it; it is recorded here rather than left to the optimistic path. Both readers are **all-or-nothing**: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared. diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md index 154e8ae54f..d4da218c4c 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md @@ -27,7 +27,7 @@ Status: implemented 条目记录的是**未转义**的发布事实。伪 XML 转义属于 `` 这层为模型而设的框架,因此只在渲染该框架时施加、从不存储;否则消费方要正确展示含 `<` 的描述就得知道框架的编码方式,本决策刚移除的框架知识会换一种形式泄漏回来。`escapeText` 确定且单射,故对未转义条目取 digest 与此前完全等价,重新发布语义不变,面向模型的文本逐字节不变。 -这次迁移同时挪动了目录的**身份**:重新发布用的 digest 现在覆盖持久条目而非渲染文本,于是面向模型的包装再也无法左右是否需要重新发布,那段从已记录消息里切出条目的文本切分逻辑也随之删除。若恢复的会话中最新目录早于本次改动,会重新发布一次——发布前阶段的姿态允许这样做。 +这次迁移同时挪动了目录的**身份**:重新发布用的 digest 现在覆盖持久条目而非渲染文本,于是面向模型的包装再也无法左右是否需要重新发布,那段从已记录消息里切出条目的文本切分逻辑也随之删除。若恢复的会话中最新目录早于本次改动,会重新发布一次——发布前阶段的姿态允许这样做。有一种情形不会自愈:当那份旧格式目录是唯一的一份、且当前视图没有任何 skill 时,插件看不到已发布目录,也就不会发出 tombstone,模型手里会留着一份无人替换的过期目录。发布前阶段的姿态(「后端拒绝旧的磁盘格式」)允许这一点;此处如实记录,而不是只写乐观路径。 两个读取器都是**全有或全无**:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml index b5cdee7d4b..7081a0d6a5 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.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 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 9dc87cd69f5d274ba5e8d66c30805cada7f1fa1a -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: e650b660efe0bf308a4b1a8c72e9c89a133ac769 +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: dfd79a1b1b44778275b4326cf5f51f6e69ce2a7c +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 9b470dda8de45ca754a04900ffac0535ebfabd96 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md index 9dc87cd69f..dfd79a1b1b 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md @@ -28,7 +28,7 @@ Keep host and runtime steering intact. Remove only the Web UI entry and chrome: ## Consequences -- **Superseded in part.** Composer steering shipped afterwards, so the first and third Decision bullets and the first consequence below no longer describe master: `InputMachine` carries the `queue`/`steer` mode union again, Web users can steer, and the steering bubble regained its user IconActions before carrying a caption again. The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required and restored the `插话` caption on steering bubbles. Host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript still hold. +- **Superseded in part.** Every clause below about the steer ENTRY, the `queue`/`steer` mode union, the interjection caption, its locale strings, and the goldens that pinned their absence no longer describes master — in the Decision that is bullets 1 and 3 through 5, and in the consequences bullets 1 and 3. Composer steering shipped afterwards, and the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required. What still holds: host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript. - Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls. - Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections. - Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one. diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md index e650b660ef..9b470dda8d 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md @@ -28,7 +28,7 @@ Status: implemented ## 后果 -- **部分被取代。** composer steering 在此之后落地,因此 Decision 的第一条与第三条、以及下面的第一条后果都已不再描述 master:`InputMachine` 重新携带 `queue`/`steer` mode 联合类型,Web 用户可以 steer,steering 气泡也早在重新带上标注之前就恢复了用户 IconActions。随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策,并恢复了 steering 气泡上的 `插话` 标注。host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍,仍然成立。 +- **部分被取代。** 下文中所有关于 steer **入口**、`queue`/`steer` mode 联合类型、插话标注、其 locale 字符串、以及钉住这些「不存在」的黄金基线的子句,均已不再描述 master——在 Decision 中是第 1 条与第 3 至 5 条,在后果中是第 1 条与第 3 条。composer steering 在此之后落地,随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策。仍然成立的是:host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍。 - Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。 - Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。 - 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 4d3c96d424..40665bfd55 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 docs/core-data-structures/core.md -core.md: d91e84a7066b004567ed9f668ded52ee486a1c99 -core.zh.md: f9cf2c14fe1021d7862d8bb14696044b7d8f7fbb +core.md: 7761930b7f133600e89befd1bda96cfc7d2f05f5 +core.zh.md: 1a68b47229e3c961e772e7b0db33ded91b025e2e diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index d91e84a706..7761930b7f 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -193,6 +193,13 @@ type ContextForm = | 'catalog' ``` +```ts type-equiv +/** Optional producer-declared {@link ContextForm}, mixed into the source shapes that carry one. */ +interface ContextFormed { + readonly form?: ContextForm +} +``` + ## Streaming Adapters emit a raw **chunk** protocol; the loop logs the chunks (replay fidelity) while feeding the same chunks through a `BlockAssembler` to rebuild blocks and messages. `StreamChunk` is a closed discriminated union over `type` — `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index f9cf2c14fe..1a68b47229 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -199,6 +199,13 @@ type ContextForm = | 'catalog' ``` +```ts type-equiv +/** Optional producer-declared {@link ContextForm}, mixed into the source shapes that carry one. */ +interface ContextFormed { + readonly form?: ContextForm +} +``` + ## 流式输出 适配器发出原始**分片**协议;循环记录分片(回放保真度),同时将同一批分片送入 `BlockAssembler` 以重建块和消息。`StreamChunk` 是基于 `type` 的封闭判别联合——`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`。 diff --git a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx index 2978fd9400..60bd38f2cf 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx @@ -13,6 +13,9 @@ import css from './ContextBody.module.css' /** Model-facing text stays bounded at the disclosure, not at the producer. */ const MAX_CHARS = 20_000 +/** Rows a list body materializes before summarizing the remainder. */ +const MAX_ENTRIES = 200 + type Translate = ChatViewSlotProps['t'] /** One durable source narrowed to the readable-record shape; null for anything else. */ @@ -61,14 +64,22 @@ function fieldValue(value: unknown, t: Translate): string { } /** - * Provenance fields as a key/value list. `kind` is omitted because the row - * header already names the producer, and `form` because the presentation the - * reader is looking at IS that value. + * Provenance fields as a key/value list. `kind` is always omitted because the + * row header already names the producer. `form` is omitted only when a + * dedicated body rendered for it — then the presentation the reader is looking + * at IS that value. On the opaque fallback the declaration is kept, because + * that is the one place a form this version cannot present would otherwise + * disappear from the UI entirely. */ -function SourceFields({ source, t }: { source: unknown; t: Translate }): ReactNode { +function SourceFields({ source, formRendered, t }: { + source: unknown + formRendered: boolean + t: Translate +}): ReactNode { const record = asRecord(source) if (record === null) return null - const rows = Object.entries(record).filter(([key]) => key !== 'kind' && key !== 'form') + const hidden = formRendered ? ['kind', 'form'] : ['kind'] + const rows = Object.entries(record).filter(([key]) => !hidden.includes(key)) if (rows.length === 0) return null return (
@@ -82,6 +93,28 @@ function SourceFields({ source, t }: { source: unknown; t: Translate }): ReactNo ) } +/** + * Content blocks this UI version does not know, kept visible rather than + * dropped: the block union is merge-extensible, so a newer or foreign log may + * carry a shape this build has no presentation for. + * @param props - The unrecognized blocks and the locale seat. + * @returns One generic JSON block per unknown entry. + */ +function UnknownBlocks({ blocks, t }: { blocks: readonly unknown[]; t: Translate }): ReactNode { + return ( + <> + {blocks.map((block, index) => ( + t('json.truncated', { total })} + /> + ))} + + ) +} + /** * The model-facing content of one context, shared by every form that shows it: * the text with its real line breaks, then any block this UI version does not @@ -97,14 +130,7 @@ function ModelFacingContent({ content, t }: { return ( <> {text !== '' &&
{boundedText(text, t)}
} - {rest.map((block, index) => ( - t('json.truncated', { total })} - /> - ))} + ) } @@ -124,14 +150,14 @@ export function OpaqueBody({ content, source, t }: { return ( <> - + ) } /** One reconciled instruction file, as the durable source records it. */ interface InstructionChange { - action: string + action: 'set' | 'replace' | 'remove' path: string digest?: string } @@ -157,14 +183,13 @@ function instructionChanges(source: unknown): InstructionChange[] | null { const path = change['path'] if (typeof path !== 'string' || path === '') return null const action = change['action'] + // The action decides which word the row shows, so an unrecognized one is + // not a readable change — it would be presented as loaded or updated. + if (action !== 'set' && action !== 'replace' && action !== 'remove') return null const digest = change['digest'] if (seen.has(path)) continue seen.add(path) - changes.push({ - action: typeof action === 'string' ? action : '', - path, - ...typeof digest === 'string' ? { digest } : {}, - }) + changes.push({ action, path, ...typeof digest === 'string' ? { digest } : {} }) } return changes.length === 0 ? null : changes } @@ -228,7 +253,9 @@ function catalogEntries(source: unknown): CatalogEntry[] | null { if (typeof name !== 'string' || name === '' || typeof description !== 'string') return null entries.push({ name, description }) } - return entries.length === 0 ? null : entries + // An empty list is a real catalog: a replacement with no entries retires + // every earlier name. Only an unreadable shape falls back. + return entries } /** @@ -249,11 +276,15 @@ export function CatalogBody({ content, source, t }: { const entries = catalogEntries(source) if (entries === null) return const update = asRecord(source)?.['update'] === true + // Entry count is unbounded (a provider may publish any number of skills), and + // the scrollport bounds height, not node count — so the list bounds itself. + const shown = entries.slice(0, MAX_ENTRIES) + const { rest } = partitionContent(content) return ( <> {update &&

{t('message.context.catalog.replaced')}

}
    - {entries.map((entry, index) => ( + {shown.map((entry, index) => ( // Index key: a hand-edited or foreign log may repeat a name, and a // duplicate React key would drop a row the model did see.
  • @@ -262,6 +293,14 @@ export function CatalogBody({ content, source, t }: {
  • ))}
+ {shown.length < entries.length && ( +

+ {t('message.context.catalog.more', { count: entries.length - shown.length })} +

+ )} + {/* The block union is merge-extensible: a catalog message carrying an + unknown block still shows it rather than dropping model-visible content. */} + ) } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 70673c145e..eceb5b2514 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -59,6 +59,7 @@ export const zh = { 'message.context.instructions.updated': '已更新', 'message.context.instructions.removed': '已移除', 'message.context.catalog.replaced': '替换目录', + 'message.context.catalog.more': '…还有 {count} 条', 'message.steering': '插话', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', @@ -178,6 +179,7 @@ export const en = { 'message.context.instructions.updated': 'updated', 'message.context.instructions.removed': 'removed', 'message.context.catalog.replaced': 'Replacement catalog', + 'message.context.catalog.more': '… {count} more', 'message.steering': 'Interjection', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index c5569fa4f1..c2d8f3acf1 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -411,13 +411,34 @@ describe('MessageItem arms', () => { .toMatch(/… 已截断,共 \d+ 字符$/) }) - it('a catalog whose source carries no entries falls back to the opaque body', () => { + it('an empty replacement catalog stays a catalog: it retires every earlier name', () => { + // `renderCatalogUpdate` legitimately publishes zero entries when the last + // skill disappears; falling back would hide that the catalog was cleared. const view = render( , + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录') + expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(0) + expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form')) + .toBe('catalog') + }) + + it('a catalog whose entries are unreadable falls back to the opaque body', () => { + const view = render( + { expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose') }) - it('a recalled session titles its row by role and names the sessions it read', () => { + it('bounds a large catalog and says how many rows it withheld', () => { + const entries = Array.from({ length: 205 }, (_, index) => ({ name: `s-${index}`, description: 'd' })) + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(200) + expect(view.container.querySelector('[data-context-entries-truncated]')?.textContent).toBe('…还有 5 条') + }) + + it('a catalog keeps a content block this version does not know', () => { const view = render( , ) - expect(view.getByRole('button', { name: /^跨会话召回\s*重构 loader$/ })).toBeTruthy() - expect(view.queryByText('上下文注入')).toBeNull() + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.getByText(/未知内容块/)).toBeTruthy() }) - it('keeps the producer name visible while the context body is expanded', () => { + it('an instruction change with an unrecognized action falls back whole', () => { + // The action decides the word the row shows, so an unknown one cannot be + // presented as loaded or updated. const view = render( , ) - const disclosure = view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md$/ }) - fireEvent.click(disclosure) - expect(disclosure.getAttribute('aria-expanded')).toBe('true') - expect(view.container.querySelector('[data-context-source]')?.textContent).toBe('AGENTS.md') + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ })) + expect(view.container.querySelector('[data-context-files]')).toBeNull() + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose') }) - it('a context source that names no producer shows the role alone', () => { + it('the opaque fallback keeps a form declaration this version cannot present', () => { + // Otherwise a newer or foreign log's declared shape vanishes from the UI. const view = render( , ) - expect(view.getByRole('button', { name: '上下文注入' })).toBeTruthy() - expect(view.container.querySelector('[data-context-source]')).toBeNull() + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*later$/ })) + const fields = [...view.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent) + expect(fields).toEqual(['plugin', 'form']) }) it('unknown nodes retain the generic JSON row', () => { diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 6ae40f6541..b0ef60d1a9 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/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/skill/tool-skill/README.md -README.md: deccda0ece1ffe2cbdb576a8af3801f28695d8d5 -README.zh.md: eaf442d9e123a2d56bf09abd9faab47a001e01b9 +README.md: c8c8b0d18665893104bc8ac65c1a90cae7816724 +README.zh.md: 696e18955bf6f622251a0ca875f53e66419ec944 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index deccda0ece..c8c8b0d186 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -10,7 +10,7 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools At every `agent/step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it injects an initial durable user-role `` before the request. Catalog messages contain only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. -Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `` framing — written for the model — cannot decide whether a republish is needed, and a consumer presenting the list never re-parses the `` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary. +Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `` framing — written for the model — cannot decide whether a republish is needed, and a consumer presenting the list never re-parses the `` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message it can read; an unreadable record is skipped like any foreign one. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary. The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index eaf442d9e1..696e18955b 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -10,7 +10,7 @@ 每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将步骤中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 ``。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。 -每条目录消息都携带 `skill-catalog` 来源:一份 `catalog` 形态的上下文,其 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖的是这些持久条目而非渲染出的散文,因此为模型书写的 `` 包装无法左右是否需要重新发布,消费方展示该列表时也不必再解析 `` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见的 `skill-catalog` 消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 +每条目录消息都携带 `skill-catalog` 来源:一份 `catalog` 形态的上下文,其 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖的是这些持久条目而非渲染出的散文,因此为模型书写的 `` 包装无法左右是否需要重新发布,消费方展示该列表时也不必再解析 `` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见且可读的 `skill-catalog` 消息作为比较基线;不可读的记录与外来记录一样被跳过。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5fd940e962..fb30d78d8c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -31,6 +31,11 @@ "symbol": "ContextForm", "source": "packages/llm/llm/src/message.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContextFormed", + "source": "packages/llm/llm/src/message.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", From 5f34f782fc9d1c76927e7bae4d1b02398dab8906 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 5 Aug 2026 17:08:51 +0800 Subject: [PATCH 8/9] fix(web): address the late review batch on context bodies - Content blocks render in the order the model received them. Partitioning hoisted every unknown block past the text around it, so an interleaved log read back in an order the model never saw. - A delta distinguishes a newly reconciled file from a rewritten one; `set` and `replace` already separate them at the producer, and collapsing both to "updated" misread a new file. - The superseded note states current fact in its consequences and testing rather than keeping claims the implementation now contradicts, per implemented/AGENTS.md; the decision itself stays as the record of that change, with the supersession pointer above it. --- ...eer-entry-or-interjection-chrome.i18n.yaml | 4 +- ...i-no-steer-entry-or-interjection-chrome.md | 12 +-- ...o-steer-entry-or-interjection-chrome.zh.md | 12 +-- .../src/client/chat/ContextBody.tsx | 74 ++++++++++++++----- .../ui-conversation/src/client/locales.ts | 2 + .../tests/chat-branch-tails.spec.tsx | 46 ++++++++++++ 6 files changed, 119 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml index 7081a0d6a5..9d11640e8f 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.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 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: dfd79a1b1b44778275b4326cf5f51f6e69ce2a7c -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 9b470dda8de45ca754a04900ffac0535ebfabd96 +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: b7d4d4fa1e8189d8c5b0c275b15c332cba1b4e6a +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 9ce5ac32797a08b4b1f95cd8b350d3f1a7148f80 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md index dfd79a1b1b..b7d4d4fa1e 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md @@ -28,12 +28,12 @@ Keep host and runtime steering intact. Remove only the Web UI entry and chrome: ## Consequences -- **Superseded in part.** Every clause below about the steer ENTRY, the `queue`/`steer` mode union, the interjection caption, its locale strings, and the goldens that pinned their absence no longer describes master — in the Decision that is bullets 1 and 3 through 5, and in the consequences bullets 1 and 3. Composer steering shipped afterwards, and the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required. What still holds: host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript. -- Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls. -- Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections. -- Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one. +- **Superseded in part.** Every clause in the Decision about the steer ENTRY, the `queue`/`steer` mode union, the interjection caption, its locale strings, and the goldens that pinned their absence no longer describes master — that is bullets 1 and 3 through 5. Composer steering shipped afterwards, and the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required and owns the caption. The consequences and testing below state current fact. +- Host steering ownership is unchanged: agent-loop drain, session events, and the wire mode remain load-bearing for ACP, automation, and any non-Web client. +- `ConversationService.send(text)` still takes no mode and always queues; the composer's Steer gesture goes through `session.prompt(mode: 'steer')` instead. +- `steering/message` still folds into the durable transcript, so an externally submitted steer stays truthful on replay. It now carries the interjection caption rather than rendering as a bare bubble. ## Testing -- `packages/client/ui-conversation` unit/jsdom coverage: input machine enter/sink, ConversationService routing, MessageItem steering arm (no 「插话」), InputBar submit. -- `apps/web/tests/steering.e2e.ts` keyless replay plus updated `settled.expected.md` (steer text without badge). +- `packages/client/ui-conversation` unit/jsdom coverage: input machine enter/sink, ConversationService routing, the MessageItem steering arm, InputBar submit. +- `apps/web/tests/steering.e2e.ts` keyless replay plus its goldens, which now pin the caption. diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md index 9b470dda8d..9ce5ac3279 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md @@ -28,12 +28,12 @@ Status: implemented ## 后果 -- **部分被取代。** 下文中所有关于 steer **入口**、`queue`/`steer` mode 联合类型、插话标注、其 locale 字符串、以及钉住这些「不存在」的黄金基线的子句,均已不再描述 master——在 Decision 中是第 1 条与第 3 至 5 条,在后果中是第 1 条与第 3 条。composer steering 在此之后落地,随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策。仍然成立的是:host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍。 -- Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。 -- Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。 -- 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。 +- **部分被取代。** Decision 中所有关于 steer **入口**、`queue`/`steer` mode 联合类型、插话标注、其 locale 字符串、以及钉住这些「不存在」的黄金基线的子句,均已不再描述 master——即第 1 条与第 3 至 5 条。composer steering 在此之后落地,随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策,并成为该标注的归属者。下面的后果与测试陈述的是当前事实。 +- host 侧 steering 的归属未变:agent-loop 排空、session 事件与线缆 mode 对 ACP、自动化以及任何非 Web 客户端仍是承重能力。 +- `ConversationService.send(text)` 仍然不接 mode、始终排队;composer 的 Steer 手势改走 `session.prompt(mode: 'steer')`。 +- `steering/message` 仍然折叠进持久 transcript,因此从外部提交的 steer 在回放时依旧如实;只是它现在带上了插话标注,而不再是一个无标识的气泡。 ## 测试 -- `packages/client/ui-conversation` unit/jsdom 覆盖:input machine enter/sink、ConversationService 路由、MessageItem steering 分支(无「插话」)、InputBar submit。 -- `apps/web/tests/steering.e2e.ts` 无密钥回放,以及更新后的 `settled.expected.md`(有 steer 正文、无徽章)。 +- `packages/client/ui-conversation` unit/jsdom 覆盖:input machine enter/sink、ConversationService 路由、MessageItem 的 steering 分支、InputBar submit。 +- `apps/web/tests/steering.e2e.ts` 无密钥回放及其黄金基线,后者现在钉住该标注。 diff --git a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx index 60bd38f2cf..1e770af57a 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx @@ -25,23 +25,36 @@ function asRecord(value: unknown): Record | null { : null } +/** One run of the model-facing content: adjacent text, or one unknown block. */ +type ContentRun = { text: string } | { block: unknown } + /** - * Concatenated text of the content blocks, with the non-text blocks kept aside. - * Context is text in practice (every producer injects one text block), but the - * block union is merge-extensible, so an unknown block keeps its own fallback - * rather than vanishing. + * The content blocks as runs, IN THE ORDER the model received them. * - * Blocks join with no separator, matching how provider adapters flatten them: - * inserting a line break here would show the reader a line the model never saw. + * Adjacent text blocks join with no separator, matching how provider adapters + * flatten them — inserting a line break would show the reader a line the model + * never saw. An unknown block breaks the run and keeps its own fallback rather + * than being hoisted past the text around it or vanishing; the block union is + * merge-extensible, so a foreign log may interleave shapes this build does not + * know. */ -function partitionContent(content: ContextMessageNode['content']): { text: string; rest: unknown[] } { - const texts: string[] = [] - const rest: unknown[] = [] +function contentRuns(content: ContextMessageNode['content']): ContentRun[] { + const runs: ContentRun[] = [] for (const block of content) { - if (block.type === 'text') texts.push(block.text) - else rest.push(block) + if (block.type !== 'text') { + runs.push({ block }) + continue + } + const last = runs[runs.length - 1] + if (last !== undefined && 'text' in last) last.text += block.text + else runs.push({ text: block.text }) } - return { text: texts.join(''), rest } + return runs +} + +/** Only the blocks this UI version does not know, for bodies that replace the text. */ +function unknownBlocks(content: ContextMessageNode['content']): unknown[] { + return contentRuns(content).flatMap(run => 'block' in run ? [run.block] : []) } /** The model-facing text, truncated to the display bound. */ @@ -126,11 +139,20 @@ function ModelFacingContent({ content, t }: { content: ContextMessageNode['content'] t: Translate }): ReactNode { - const { text, rest } = partitionContent(content) return ( <> - {text !== '' &&
{boundedText(text, t)}
} - + {contentRuns(content).map((run, index) => ('text' in run + ? run.text !== '' && ( +
{boundedText(run.text, t)}
+ ) + : ( + t('json.truncated', { total })} + /> + )))} ) } @@ -194,6 +216,24 @@ function instructionChanges(source: unknown): InstructionChange[] | null { return changes.length === 0 ? null : changes } +/** + * Locale key for one reconciled file. The baseline loads a file; a later delta + * distinguishes a newly reconciled path from a rewritten one, which `set` and + * `replace` already separate at the producer. + * @param action - the durable change action. + * @param baseline - whether this context is the startup/resume baseline. + * @returns the key naming what happened to that file. + */ +function instructionAction( + action: InstructionChange['action'], + baseline: boolean, +): 'message.context.instructions.removed' | 'message.context.instructions.loaded' + | 'message.context.instructions.added' | 'message.context.instructions.updated' { + if (action === 'remove') return 'message.context.instructions.removed' + if (baseline) return 'message.context.instructions.loaded' + return action === 'set' ? 'message.context.instructions.added' : 'message.context.instructions.updated' +} + /** * `instructions` form: the files this context reconciled, then their text. * @@ -218,7 +258,7 @@ export function InstructionsBody({ content, source, t }: {
  • {change.path} - {t(`message.context.instructions.${change.action === 'remove' ? 'removed' : baseline ? 'loaded' : 'updated'}`)} + {t(instructionAction(change.action, baseline))}
  • ))} @@ -279,7 +319,7 @@ export function CatalogBody({ content, source, t }: { // Entry count is unbounded (a provider may publish any number of skills), and // the scrollport bounds height, not node count — so the list bounds itself. const shown = entries.slice(0, MAX_ENTRIES) - const { rest } = partitionContent(content) + const rest = unknownBlocks(content) return ( <> {update &&

    {t('message.context.catalog.replaced')}

    } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index eceb5b2514..9b7d24480f 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -56,6 +56,7 @@ export const zh = { 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', 'message.context.instructions.loaded': '已载入', + 'message.context.instructions.added': '已新增', 'message.context.instructions.updated': '已更新', 'message.context.instructions.removed': '已移除', 'message.context.catalog.replaced': '替换目录', @@ -176,6 +177,7 @@ export const en = { 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', 'message.context.instructions.loaded': 'loaded', + 'message.context.instructions.added': 'added', 'message.context.instructions.updated': 'updated', 'message.context.instructions.removed': 'removed', 'message.context.catalog.replaced': 'Replacement catalog', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index c2d8f3acf1..327265d870 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -288,6 +288,52 @@ describe('MessageItem arms', () => { .toContain('') }) + it('a delta distinguishes a newly reconciled file from a rewritten one', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*new\/AGENTS\.md, old\/AGENTS\.md$/ })) + const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent) + expect(files).toEqual(['new/AGENTS.md已新增', 'old/AGENTS.md已更新']) + }) + + it('keeps an interleaved unknown block in the order the model received it', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: '上下文注入' })) + const texts = [...view.container.querySelectorAll('[data-context-text]')].map(node => node.textContent) + expect(texts).toEqual(['before', 'after']) + expect(view.getByText(/未知内容块/)).toBeTruthy() + }) + it('the catalog form lists its durable entries instead of the model-facing prose', () => { const view = render( Date: Thu, 6 Aug 2026 11:55:14 +0800 Subject: [PATCH 9/9] test(stack): cover synced context regressions --- apps/web/tests/snapshots/plan-review/approved.expected.md | 2 +- packages/skill/tool-skill/tests/tool-skill.spec.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 71876c40ae..fe30f4138c 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - img -- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Interjection Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img - button "Branch into a new conversation" [disabled]: diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 4dcfd044ad..5d14b7c523 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -409,6 +409,10 @@ describe('dsh-tool-skill', () => { const home = await tempDir('tool-proposed-empty-catalog') const ctx = await setup(home) const session = Session.create(SessionId('proposed-empty-catalog')) + const malformed = createUserMessage({ + content: [{ type: 'text', text: 'preserve unreadable claimed context' }], + source: { kind: 'skill-catalog', form: 'catalog' } as never, + }) const stale = createUserMessage({ content: catalogContent(['- `stale-skill`: Stale skill']), source: { @@ -418,9 +422,9 @@ describe('dsh-tool-skill', () => { }, }) - const decision = await proposeStep(ctx, sessionAgent(session), [stale]) + const decision = await proposeStep(ctx, sessionAgent(session), [malformed, stale]) - expect(decision).toEqual({ kind: 'enter', messages: [] }) + expect(decision).toEqual({ kind: 'enter', messages: [malformed] }) }) it('keeps a proposed catalog that already matches the current snapshot', async () => {