From 568866d5a8d28223ba266ddeb9d6d7792be9aad5 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 17:20:20 +0800 Subject: [PATCH 001/114] fix(web): orient agents to the running GUI --- ...-07-28-web-agent-runtime-context.i18n.yaml | 6 ++++ .../2026-07-28-web-agent-runtime-context.md | 33 +++++++++++++++++++ ...2026-07-28-web-agent-runtime-context.zh.md | 33 +++++++++++++++++++ apps/cli/README.i18n.yaml | 6 ++-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/cordis.yml | 3 +- apps/cli/src/web.ts | 24 ++++++++++++++ apps/web/tests/replay-round-trip.e2e.ts | 22 +++++++++++-- apps/web/tests/scaffold.ts | 2 ++ .../system-prompt.expected.md | 7 ++++ 11 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md create mode 100644 apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.i18n.yaml new file mode 100644 index 0000000000..fdf6fbca4e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.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/bug-fix/2026-07-28-web-agent-runtime-context.md +2026-07-28-web-agent-runtime-context.md: 7e2e0beb4057db2533d8360e39b46199900f067b +2026-07-28-web-agent-runtime-context.zh.md: add98653d24b7bde84982ca23bb60cdb234f98d0 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md new file mode 100644 index 0000000000..7e2e0beb40 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md @@ -0,0 +1,33 @@ +# Agent Note: Web agents receive explicit runtime context + +Status: implemented + +English | [中文](2026-07-28-web-agent-runtime-context.zh.md) + +## Problem + +The Web composition configured an empty deployment persona and added no launcher-owned source or interaction-surface section. A session header recorded its working directory for tools and persistence, but the model prompt did not state that directory or identify the DeepSeek Harness Web GUI. A request such as “change this page's theme” therefore made the agent search the selected project for an unspecified page, even when the user meant the GUI running the session. + +## Decision + +The shared Web/headless composition supplies a concise coding-agent persona containing the resolved `{{model}}` and session `{{cwd}}`. `dsh web` additionally resolves the harness checkout from the launcher's module URL, installs the existing `harness:source` section, and adds an `app:web-surface` section before serving requests. + +The Web section treats unqualified references to “this page,” “this GUI,” or “this app” as references to the DeepSeek Harness Web GUI. It also states that the browser provides no implicit DOM, route, or screenshot context, so the model can identify the product without claiming visual state it did not receive. The assembled text is logged in `request/header`, preserving the model-visible/logged invariant. + +## Verification + +The keyless fresh-round-trip Web scenario boots the shipped composition, installs the same launcher context as `dsh web`, runs a real session through the HTTP/SSE application, and snapshots the first four system-prompt sections with source and working-directory paths normalized. The snapshot pins the harness identity, source checkout, Web orientation, and resolved coding-agent persona in request order. + +## Alternatives considered + +**Send URL, DOM, or screenshots with every prompt.** The observed failure needed stable product orientation, while the current root URL does not identify a selected component and no visual capture exists in the message contract. Adding dynamic page state would require a separate logged model-input design and is not implied by this fix. + +**Require the session Workspace to be the harness checkout.** Workspace cwd is the user's task target and may legitimately be an empty project or another repository. Conflating it with the application's source location would break that boundary and leave installed or externally launched sessions ambiguous. + +**Put Web wording in the global harness identity.** `dsh-system-prompt` serves TUI, ACP, SDK, and custom deployments that do not run in a browser. The composing Web app owns this surface fact. + +**Change the existing source-location section for every CLI surface.** The source section is shared with TUI and states only the checkout fact. Keeping Web orientation separate preserves that reusable contract and avoids telling headless or terminal agents that they are in a browser. + +## Consequences + +Web requests gain a short stable prompt prefix and may invalidate provider prefix caches once when this change is deployed. Agents can distinguish the GUI source checkout from the selected Workspace and resolve ordinary references to the current app without a clarification round trip. References to a specific visual state remain bounded by the explicit no-DOM/no-route/no-screenshot statement and may still require a path, description, or attachment. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md new file mode 100644 index 0000000000..add98653d2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web agent 获得显式运行时上下文 + +Status: implemented + +[English](2026-07-28-web-agent-runtime-context.md) | 中文 + +## 问题 + +Web 组合把部署 persona 配置为空,也没有添加由启动器拥有的源码位置或交互界面提示词段。会话 header 会记录工作目录,供工具与持久化使用,但模型提示词既不说明该目录,也不标识 DeepSeek Harness Web GUI。因此,当用户提出「修改这个页面的主题」之类的请求时,即使用户指的是承载当前会话的 GUI,agent 也只能在所选项目中搜索一个未明确说明的页面。 + +## 决策 + +Web/无头共享组合提供一段简洁的编码 agent persona,其中包含解析后的 `{{model}}` 与会话 `{{cwd}}`。`dsh web` 还会根据启动器模块的 URL 解析 harness checkout,安装现有的 `harness:source` 提示词段,并在对外提供请求服务前添加 `app:web-surface` 提示词段。 + +Web 提示词段把未限定的「这个页面」「这个 GUI」或「这个应用」解释为 DeepSeek Harness Web GUI。同时,它会明确说明浏览器不会隐式提供 DOM、路由或截图上下文,使模型能够识别产品,但不会声称掌握未收到的视觉状态。组装后的文本会记录在 `request/header` 中,从而保持「模型可见内容必须有日志记录」这一不变量。 + +## 验证 + +无密钥的 Web fresh-round-trip 场景会启动已交付组合,安装与 `dsh web` 相同的启动器上下文,并通过 HTTP/SSE 应用运行一个真实会话。测试会把源码路径和工作目录规范化,然后对系统提示词的前四个段落生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。 + +## 考虑过的替代方案 + +**每次提示词都发送 URL、DOM 或截图。** 本次故障只需要稳定的产品定位;当前根 URL 无法标识所选组件,消息契约中也不存在视觉捕获内容。添加动态页面状态需要另行设计可记录的模型输入,不属于本次修复的隐含范围。 + +**要求会话 Workspace 必须是 harness checkout。** Workspace cwd 是用户任务的目标,可以合理地指向空项目或其他仓库。将其与应用源码位置混为一谈会破坏这一边界,并且仍无法消除已安装版本或外部启动会话中的歧义。 + +**把 Web 文案放入全局 harness 身份。** `dsh-system-prompt` 还服务于 TUI、ACP、SDK 和不在浏览器中运行的自定义部署。该界面事实应由组装 Web 应用负责。 + +**为所有 CLI 界面修改现有源码位置提示词段。** TUI 也复用源码位置提示词段,而该段只陈述 checkout 事实。单独保留 Web 界面定位可以维持这份可复用契约,避免错误地告诉无头或终端 agent 它们正处于浏览器中。 + +## 影响 + +Web 请求会增加一段较短且稳定的提示词前缀;部署此变更时,模型提供方的前缀缓存可能失效一次。agent 可以区分 GUI 源码 checkout 与所选 Workspace,并且无需再经过一轮澄清即可解析对当前应用的一般指代。对特定视觉状态的指代仍受「无 DOM/无路由/无截图」这一显式边界约束,必要时仍需用户提供路径、描述或附件。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index abe51abc2f..d1c6290a08 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 42d2a9641cf5d497c9aae45d9f60fce4498addb9 -README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de +# pnpm run verify-translation-pairing --write apps/cli/README.md +README.md: 87cf1597fbb1ce91a7d61f912c42b0d6a68b2eea +README.zh.md: b3743f57a4dafda71e437a04ee0148b32dba2dca diff --git a/apps/cli/README.md b/apps/cli/README.md index 42d2a9641c..87cf1597fb 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both tell the coding agent its model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Web additionally names the DeepSeek Harness Web GUI as the interaction surface and this checkout as its own source location; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 0a62f8bb72..b3743f57a4 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI,并把当前 checkout 标记为自身源码位置;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 5397c08746..b54200247b 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -40,7 +40,8 @@ - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' config: - persona: '' + persona: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. - id: tools name: '@deepseek-ai/dsh-tools' diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 31282c8f5f..599d788d89 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -8,9 +8,32 @@ import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' +import type { Context } from 'cordis' +import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' +import type {} from '@deepseek-ai/dsh-system-prompt' import { AppCLIEntry } from './app-cli-entry.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) + +/** Stable model-visible orientation for sessions created through `dsh web`. */ +export const WEB_SURFACE_PROMPT = 'You are interacting with the user through the DeepSeek Harness Web GUI. ' + + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' + + 'The browser provides no implicit DOM, route, or screenshot context.' + +/** + * Add the launcher-owned source location and Web-surface orientation after the + * shared config tree settles. The request header logs both sections with every + * model-visible prompt. + * @param ctx - settled Web application context. + * @param sourceRoot - absolute checkout root resolved from the launcher module. + */ +export function installWebPromptContext(ctx: Context, sourceRoot: string): void { + const systemPrompt = ctx.get('systemPrompt') + if (systemPrompt === undefined) throw new Error('dsh web: systemPrompt service missing after settled boot') + addHarnessSourceSection(ctx, sourceRoot) + systemPrompt.section({ name: 'app:web-surface', order: -98, text: WEB_SURFACE_PROMPT }) +} // Display-only mirrors of the webserver schema's allowed hosts: the loopback // address the local URL always prints, and the all-interfaces value that gates @@ -40,6 +63,7 @@ export async function runWeb( ...workspaceRoot !== undefined && { workspaceRoot }, }) const { ctx, port: boundPort } = await entry.run() + installWebPromptContext(ctx, SOURCE_ROOT) let exiting = false const shutdown = (code: number): void => { diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index f4cf960cda..222bb28580 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -9,20 +9,22 @@ // Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. import { readFile } from 'node:fs/promises' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, REPO_ROOT, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url)) const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/ui.expected.md', import.meta.url)) +const SYSTEM_PROMPT_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/system-prompt.expected.md', import.meta.url)) const MODE = webSnapshotMode() // The scenario's one drive prompt. Record sends it; replay asserts the @@ -35,6 +37,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { let browser: Browser let page: Page let tripwire: ReturnType + let settledSessionId: SessionId | undefined const sessionEvents: SessionEvent[] = [] beforeAll(async () => { @@ -69,11 +72,24 @@ describe('web e2e: fresh round trip through the real assembly', () => { await input.fill(PROMPT) await input.press('Enter') const sessionId = await settled + settledSessionId = sessionId if (MODE === 'record') { await recordFixture(scaffold, sessionId, FIXTURE) } }, 200_000) + it('records the Web surface, source checkout, and session cwd in the request header', async () => { + if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id') + const agent = scaffold.ctx.agents.get(settledSessionId) + if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`) + const system = agent.session.requestHeader()?.system + if (system === undefined) throw new Error('the settled Web request has no system prompt') + const prefix = system.split('\n\n').slice(0, 4).join('\n\n') + .split(REPO_ROOT).join('{{sourceRoot}}') + .split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}') + await compareOrRefreshGolden(SYSTEM_PROMPT_EXPECTED, prefix, MODE) + }) + it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled')) // Browser settled-poll after host completion (host strictly precedes render). @@ -129,6 +145,6 @@ describe('web e2e: fresh round trip through the real assembly', () => { it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'system-prompt.expected.md', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f722ead61b..ac360f38f2 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -44,6 +44,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' +import { installWebPromptContext } from '../../cli/src/web.ts' import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts' /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */ @@ -202,6 +203,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Tue, 28 Jul 2026 17:31:48 +0800 Subject: [PATCH 002/114] fix(web): keep surface prompt module-private --- apps/cli/src/web.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 599d788d89..67da6c73e8 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -17,7 +17,7 @@ const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /** Stable model-visible orientation for sessions created through `dsh web`. */ -export const WEB_SURFACE_PROMPT = 'You are interacting with the user through the DeepSeek Harness Web GUI. ' +const WEB_SURFACE_PROMPT = 'You are interacting with the user through the DeepSeek Harness Web GUI. ' + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' + 'The browser provides no implicit DOM, route, or screenshot context.' From 544d543ad168312353457e785bf4851df4aed4fc Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 18:04:21 +0800 Subject: [PATCH 003/114] fix(web): close the GUI update feedback loop --- ...2026-07-28-web-gui-feedback-loop.i18n.yaml | 6 +++ .../2026-07-28-web-gui-feedback-loop.md | 37 +++++++++++++++++ .../2026-07-28-web-gui-feedback-loop.zh.md | 37 +++++++++++++++++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/web.ts | 41 ++++++++++++++----- apps/cli/tsconfig.json | 3 ++ apps/web/package.json | 2 +- apps/web/tests/replay-round-trip.e2e.ts | 21 ++++++++++ apps/web/tests/scaffold.ts | 2 +- .../system-prompt.expected.md | 2 +- apps/web/tests/vite-entry.e2e.ts | 29 +++++++++++++ apps/web/vite.config.ts | 17 +++++++- .../host/webserver/tests/webserver.spec.ts | 2 + 15 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md create mode 100644 apps/web/tests/vite-entry.e2e.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml new file mode 100644 index 0000000000..22390d728f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.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/bug-fix/2026-07-28-web-gui-feedback-loop.md +2026-07-28-web-gui-feedback-loop.md: 27295704d7b0cde3a46a6a28891545bfe31ed275 +2026-07-28-web-gui-feedback-loop.zh.md: 06367b09bd889bfb7a4052c46369720c4ec6d358 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md new file mode 100644 index 0000000000..27295704d7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md @@ -0,0 +1,37 @@ +# Agent Note: Web GUI changes close the loop on the existing URL + +Status: implemented + +English | [中文](2026-07-28-web-gui-feedback-loop.zh.md) + +## Problem + +The Web agent could identify neither the GUI hosting its session nor the URL the user was viewing. The [runtime-context decision](2026-07-28-web-agent-runtime-context.md) supplies the first fact, but a GUI edit still had no executable acceptance target: source edits, artifact builds, a listening process, and the user's existing page were unrelated observations. Repository affordances made a wrong substitute look valid because `apps/web/package.json` exposed `vite` as its `dev` script and bare Vite returned HTTP 200 even though it could not inject `window.__DSH_BOOT__`. + +The incident session recorded three consecutive failures. After changing the theme, turn 2 delegated acceptance to the user with `pnpm run demo:tui` or an unspecified browser application and ran no assembled Web check. Turn 3 read the frontend package script, launched bare Vite on port 5173, treated HTTP 200 as readiness, and reported success; the user instead received the expected missing-`__DSH_BOOT__` white screen. Turn 4 found `dsh web`, rebuilt the shell, started an unmanaged shell-background process on port 3334, and checked only that the new page returned 200 with a boot manifest. It never probed the existing port 3081. In fact, the port-3081 process predated the build, and its static host read the rebuilt dist on the next request, so refreshing the original page already showed the change. Only after the user reported that fact did turn 5 inspect port 3081 and remove the redundant server. + +## Decision + +`dsh web` publishes one canonical loopback URL as both model-visible orientation and a managed shell fact. The `app:web-surface` prompt section says that unqualified references identify this GUI, names the URL, and defines acceptance as rebuilding the affected Web artifacts and verifying that existing URL after refresh. `DSH_WEB_URL` carries the same value into every foreground or managed background bash call, so the agent can query the target without parsing prose or process listings. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. + +The `apps/web` development script and Vite configuration reject serve mode before opening a port. Their diagnostics identify `apps/web` as a build-only shell, explain that only `dsh web` injects `window.__DSH_BOOT__`, and name the production and HMR entry paths. Vite build mode remains unchanged. + +No server restart or replacement is required merely because static artifacts changed. The host reads `index.html` and static assets on each request, while client bundles are also served from their current files with `no-cache`; a refresh of the existing URL is therefore the acceptance path after the relevant shell and plugin bundles are rebuilt. Starting a separate server proves only that a separate server works. If the user explicitly requests another long-running server, the existing managed background-task contract owns its lifecycle and completion notices; shell `&` is not an alternative lifecycle. + +## Verification + +The keyless fresh-round-trip browser scenario boots the shipped Web composition, drives a real replayed session, snapshots the URL-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` equals the scaffold's actual bound URL. A real Vite subprocess test requires serve mode to exit nonzero with the full-host correction. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, and HTTP bytes rather than an agent's success statement. + +## Alternatives considered + +**Extend only the system prompt.** Rejected because it would leave the target unavailable to tools, preserve the misleading bare-Vite path, and fail to prove how an existing process observes rebuilt artifacts. + +**Remove the `apps/web` development script without guarding Vite.** Rejected because `npx vite`, the exact incident command, bypasses package scripts. Serve mode itself must fail. + +**Automatically restart or replace the current Web process after every edit.** Rejected because the static server already reads current artifacts per request, a restart would interrupt the session that requested the edit, and plugin HMR has a separate explicit `dsh web --dev` composition. + +**Send DOM, route, or screenshots with each request.** Deferred to a separate logged-input design. Stable URL identity closes this feedback loop without claiming browser state the host does not receive. + +## Consequences + +Web prompts gain a dynamic URL paragraph, so provider prefix reuse now varies by bound port. Bash processes gain one non-secret managed environment variable. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the unsupported startup path fails before a white screen, and a second port can no longer masquerade as proof that the user's current page changed. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md new file mode 100644 index 0000000000..06367b09bd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Web GUI 改动在现有 URL 上闭环 + +Status: implemented + +[English](2026-07-28-web-gui-feedback-loop.md) | 中文 + +## 问题 + +Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道用户正在查看哪个 URL。[运行时上下文决策](2026-07-28-web-agent-runtime-context.md)提供前一项事实,但 GUI 编辑仍然没有可执行的验收目标:源码编辑、产物构建、监听中的进程与用户已打开的页面只是互不关联的观察结果。仓库提供的入口让错误的替代方案显得合理,因为 `apps/web/package.json` 将 `vite` 暴露为 `dev` 脚本,而裸 Vite 即使无法注入 `window.__DSH_BOOT__`,仍会返回 HTTP 200。 + +事故会话记录了连续三次失败。修改主题后,第 2 轮把验收交给用户,要求用户运行 `pnpm run demo:tui` 或打开某个未指明的浏览器应用,自己没有执行任何真实组装的 Web 验证。第 3 轮读取前端包脚本,在 5173 端口启动裸 Vite,把 HTTP 200 当作就绪并报告成功;用户看到的却是符合预期的缺少 `__DSH_BOOT__` 的白屏。第 4 轮找到 `dsh web`,重新构建 Web 外壳,在 3334 端口启动了一个不受管理的 shell 后台进程,并且只检查新页面是否能返回 200 和启动 manifest(元数据清单),始终没有探测现有的 3081 端口。事实上,3081 端口的进程早于此次构建启动,其静态宿主会在下一次请求时读取重新构建的 dist,因此刷新原页面就已经能看到改动。直到用户报告这一事实,第 5 轮才检查 3081 端口并移除冗余服务。 + +## 决策 + +`dsh web` 发布一个规范的回环 URL,同时将其作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 系统提示词段说明:未加限定的指代指向此 GUI;该段会给出 URL,并把验收定义为重新构建受影响的 Web 产物,然后刷新并验证现有 URL。`DSH_WEB_URL` 会把同一个值传入每次前台或受管后台 bash 调用,使 agent 无需解析提示词或进程列表即可查询目标。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。 + +`apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR(热模块替换)入口路径。Vite 构建模式保持不变。 + +静态产物发生变化时,不需要仅为此重启或替换服务器。宿主会在每次请求时读取 `index.html` 和静态资源,客户端 bundle 也会从当前文件提供,并设置 `no-cache`;因此,重新构建相关外壳与插件 bundle 后,刷新现有 URL 就是验收路径。启动另一个服务器只能证明另一个服务器可用。如果用户明确要求再启动一个长期运行的服务器,则现有受管后台任务契约负责其生命周期和完成通知;shell `&` 不能替代这套生命周期机制。 + +## 验证 + +无密钥的 fresh-round-trip 浏览器场景会启动已交付的 Web 组合,驱动真实的回放会话,对包含 URL 的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 等于测试脚手架实际绑定的 URL。真实 Vite 子进程测试要求服务模式以非零状态退出,并给出改用完整宿主的纠正信息。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出和 HTTP 字节,而不是 agent 的成功声明。 + +## 考虑过的替代方案 + +**仅扩展系统提示词。** 不予采纳,因为这样会让工具仍然无法获得目标,保留具有误导性的裸 Vite 路径,并且无法证明现有进程如何观察重新构建的产物。 + +**删除 `apps/web` 开发脚本,但不为 Vite 添加防护。** 不予采纳,因为事故中实际使用的命令 `npx vite` 会绕过包脚本。服务模式本身必须失败。 + +**每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而插件 HMR 已有独立且显式的 `dsh web --dev` 组合。 + +**每次请求都发送 DOM、路由或截图。** 推迟到另行设计的已记录输入机制。稳定的 URL 身份足以闭合本次反馈循环,同时不会声称宿主掌握其未接收的浏览器状态。 + +## 影响 + +Web 提示词会增加一个动态 URL 段落,因此模型提供方的前缀复用会随绑定端口变化。Bash 进程会增加一个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,不受支持的启动路径会在出现白屏前失败,另一个端口也无法再冒充用户当前页面已经改动的证据。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index d1c6290a08..bd14674ff9 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 87cf1597fbb1ce91a7d61f912c42b0d6a68b2eea -README.zh.md: b3743f57a4dafda71e437a04ee0148b32dba2dca +README.md: 3ec427a0bca501d70c9bca938692d6ef9557a2dd +README.zh.md: e0cf7cd399858822df613a98890c0872554a5085 diff --git a/apps/cli/README.md b/apps/cli/README.md index 87cf1597fb..3ec427a0bc 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both tell the coding agent its model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Web additionally names the DeepSeek Harness Web GUI as the interaction surface and this checkout as its own source location; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both tell the coding agent its model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL in both the prompt and `$DSH_WEB_URL`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. The Web host reads frontend dist and client bundles when requests arrive, so rebuilding the affected artifacts and refreshing the existing URL updates that GUI without replacing its process; bare `apps/web` Vite serving fails because it cannot inject `window.__DSH_BOOT__`. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index b3743f57a4..e0cf7cd399 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI,并把当前 checkout 标记为自身源码位置;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词和 `$DSH_WEB_URL` 中提供该进程的规范本地 URL;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。Web 宿主会在收到请求时读取前端 dist 和客户端 bundle,因此重新构建受影响的产物并刷新现有 URL 即可更新该 GUI,无须替换其进程;直接使用裸 `apps/web` Vite 服务会失败,因为它无法注入 `window.__DSH_BOOT__`。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 67da6c73e8..97e5f94e18 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -11,28 +11,47 @@ import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-tool-bash' import { AppCLIEntry } from './app-cli-entry.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) -/** Stable model-visible orientation for sessions created through `dsh web`. */ -const WEB_SURFACE_PROMPT = 'You are interacting with the user through the DeepSeek Harness Web GUI. ' - + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' - + 'The browser provides no implicit DOM, route, or screenshot context.' +const DSH_WEB_URL = 'DSH_WEB_URL' as const + +/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ +function webSurfacePrompt(webUrl: string): string { + return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. ` + + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' + + 'The browser provides no implicit DOM, route, or screenshot context. ' + + 'For changes to this GUI, rebuild the affected Web artifacts and verify this existing URL after a refresh; starting another server does not update this GUI. ' + + 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. ' + + 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.' +} /** - * Add the launcher-owned source location and Web-surface orientation after the - * shared config tree settles. The request header logs both sections with every - * model-visible prompt. + * Add launcher-owned source, Web-surface orientation, and the shell-visible + * canonical URL after the shared config tree settles. The request header logs + * the model-visible sections; each bash execution receives the same URL through + * the managed environment. * @param ctx - settled Web application context. * @param sourceRoot - absolute checkout root resolved from the launcher module. + * @param webUrl - canonical loopback URL printed by this Web process. */ -export function installWebPromptContext(ctx: Context, sourceRoot: string): void { +export function installWebPromptContext(ctx: Context, sourceRoot: string, webUrl: string): void { const systemPrompt = ctx.get('systemPrompt') if (systemPrompt === undefined) throw new Error('dsh web: systemPrompt service missing after settled boot') + const bashEnv = ctx.get('bashEnv') + if (bashEnv === undefined) throw new Error('dsh web: bashEnv service missing after settled boot') addHarnessSourceSection(ctx, sourceRoot) - systemPrompt.section({ name: 'app:web-surface', order: -98, text: WEB_SURFACE_PROMPT }) + systemPrompt.section({ name: 'app:web-surface', order: -98, text: webSurfacePrompt(webUrl) }) + bashEnv.register({ + name: 'web-runtime', + variables: { + [DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' }, + }, + resolve: () => ({ [DSH_WEB_URL]: webUrl }), + }) } // Display-only mirrors of the webserver schema's allowed hosts: the loopback @@ -63,7 +82,8 @@ export async function runWeb( ...workspaceRoot !== undefined && { workspaceRoot }, }) const { ctx, port: boundPort } = await entry.run() - installWebPromptContext(ctx, SOURCE_ROOT) + const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` + installWebPromptContext(ctx, SOURCE_ROOT, localUrl) let exiting = false const shutdown = (code: number): void => { @@ -76,7 +96,6 @@ export async function runWeb( ? Object.values(networkInterfaces()).flat() .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) : undefined - const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`) process.on('SIGTERM', () => { shutdown(0) }) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 05947889b2..f56e97cb60 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../packages/ui/app-boot" }, + { + "path": "../../packages/bash/tool-bash" + }, { "path": "../../packages/ui/tui" }, diff --git a/apps/web/package.json b/apps/web/package.json index 3c8f90b6a0..722ebe5342 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ }, "scripts": { "build": "vite build", - "dev": "vite", + "dev": "node -e \"console.error('apps/web is build-only; run dsh web or dsh web --dev with pnpm run dev:web') ; process.exit(1)\"", "watch": "vite build --watch" }, "license": "BSD-3-Clause", diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 222bb28580..a6434cd0d1 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, @@ -87,9 +88,29 @@ describe('web e2e: fresh round trip through the real assembly', () => { const prefix = system.split('\n\n').slice(0, 4).join('\n\n') .split(REPO_ROOT).join('{{sourceRoot}}') .split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}') + .split(scaffold.baseUrl).join('{{webUrl}}') await compareOrRefreshGolden(SYSTEM_PROMPT_EXPECTED, prefix, MODE) }) + it('exposes the assembled Web URL to the real bash tool', async () => { + if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id') + const agent = scaffold.ctx.agents.get(settledSessionId) + if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`) + const result = await scaffold.ctx.tools.execute({ + signal: AbortSignal.timeout(5_000), + callId: CallId('web-url-probe'), + name: 'bash', + arguments: { + command: 'printf \'%s\\n\' "$DSH_WEB_URL"', + description: 'Print current Web URL', + }, + agent, + }) + expect(result.isError).toBe(false) + expect(result.content.filter(block => block.type === 'text').map(block => block.text).join('')) + .toBe(`${scaffold.baseUrl}\n`) + }) + it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled')) // Browser settled-poll after host completion (host strictly precedes render). diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index ac360f38f2..8c9ec1dd30 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -203,12 +203,12 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { + it('rejects the package dev alias with the full-host correction', async () => { + const result = await execa('pnpm', ['run', 'dev'], { cwd: WEB_ROOT, reject: false }) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('apps/web is build-only') + expect(result.stderr).toContain('dsh web') + }) + + it('rejects the standalone Vite server with the full-host correction', async () => { + const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', '0'], { + cwd: WEB_ROOT, + reject: false, + timeout: 2_000, + }) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('apps/web is not a standalone application') + expect(result.stderr).toContain('dsh web') + expect(result.stderr).toContain('window.__DSH_BOOT__') + }) +}) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 7043de4911..659dfa7ebd 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,11 +1,26 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' +import type { Plugin } from 'vite' import react from '@vitejs/plugin-react' const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) +const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. ' + + 'Build with `pnpm run build && pnpm run build:web`, then run `dsh web` (repository checkout: `pnpm run dsh -- web`). ' + + 'For client-plugin HMR, run `pnpm run dsh -- web --dev` together with `pnpm run dev:web`.' + +/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ +function rejectStandaloneServe(): Plugin { + const reject = (): never => { throw new Error(STANDALONE_ERROR) } + return { + name: 'dsh-reject-standalone-web-serve', + apply: 'serve', + configureServer: reject, + configurePreviewServer: reject, + } +} export default defineConfig({ - plugins: [react()], + plugins: [rejectStandaloneServe(), react()], resolve: { // Workspace packages resolve to SOURCE: package.json exports point at lib // for Node/type consumers, but the browser bundle must compile src directly diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index c4373d2e50..017fedba1a 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -111,6 +111,8 @@ describe('real Loader composition', () => { // Static fallback semantics: real asset served, traversal 403, non-GET/ // HEAD without a matching route 405. expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' }) + await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true') + expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' }) expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403) expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405) From d8004e9956cc11a3678991db9dbbd87385602c1a Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 28 Jul 2026 22:12:00 +0800 Subject: [PATCH 004/114] test(web): prove Vite never binds a port --- apps/web/tests/vite-entry.e2e.ts | 31 +++++++++++++++++++++++++++++-- apps/web/vite.config.ts | 7 +++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/vite-entry.e2e.ts b/apps/web/tests/vite-entry.e2e.ts index 80b9e2e683..131526819f 100644 --- a/apps/web/tests/vite-entry.e2e.ts +++ b/apps/web/tests/vite-entry.e2e.ts @@ -2,11 +2,28 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' +import { createServer } from 'node:net' import { execa } from 'execa' import { describe, expect, it } from 'vitest' const WEB_ROOT = fileURLToPath(new URL('..', import.meta.url)) +/** Reserve an available loopback port, then release it for the child invocation. */ +async function freePort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('port probe returned no address') + await new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) + return address.port +} + describe('Web development entry', () => { it('rejects the package dev alias with the full-host correction', async () => { const result = await execa('pnpm', ['run', 'dev'], { cwd: WEB_ROOT, reject: false }) @@ -16,14 +33,24 @@ describe('Web development entry', () => { }) it('rejects the standalone Vite server with the full-host correction', async () => { - const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', '0'], { + const port = await freePort() + const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', String(port)], { cwd: WEB_ROOT, reject: false, - timeout: 2_000, + timeout: 10_000, }) + expect(result.timedOut).toBe(false) expect(result.exitCode).not.toBe(0) expect(result.stderr).toContain('apps/web is not a standalone application') expect(result.stderr).toContain('dsh web') expect(result.stderr).toContain('window.__DSH_BOOT__') + await expect(new Promise((resolve, reject) => { + const probe = createServer() + probe.once('error', reject) + probe.listen(port, '127.0.0.1', () => probe.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) + })).resolves.toBeUndefined() }) }) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 659dfa7ebd..d624fc2472 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,12 +10,11 @@ const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite ca /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ function rejectStandaloneServe(): Plugin { - const reject = (): never => { throw new Error(STANDALONE_ERROR) } return { name: 'dsh-reject-standalone-web-serve', - apply: 'serve', - configureServer: reject, - configurePreviewServer: reject, + config(_config, env) { + if (env.command === 'serve') throw new Error(STANDALONE_ERROR) + }, } } From cd88a339fa885be82194e1881f49bf8be46190fe Mon Sep 17 00:00:00 2001 From: NI0317 Date: Wed, 29 Jul 2026 11:22:48 +0800 Subject: [PATCH 005/114] fix(web): verify current GUI updates end to end --- ...2026-07-28-web-gui-feedback-loop.i18n.yaml | 4 +- .../2026-07-28-web-gui-feedback-loop.md | 10 +- .../2026-07-28-web-gui-feedback-loop.zh.md | 12 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/args.ts | 2 +- apps/cli/src/web.ts | 27 +++- apps/web/package.json | 2 +- apps/web/tests/hmr-live.e2e.ts | 132 ++++++++++++++++++ apps/web/tests/replay-round-trip.e2e.ts | 6 +- apps/web/tests/scaffold.ts | 2 +- apps/web/tests/smoke-real.e2e.ts | 12 +- .../system-prompt.expected.md | 2 +- .../development-prompt.expected.md | 1 + apps/web/tests/support/listen-probe.mjs | 9 ++ apps/web/tests/vite-entry.e2e.ts | 47 ++++--- apps/web/tsconfig.json | 1 + ...0003-web-agent-gui-feedback-loop.i18n.yaml | 6 + .../0003-web-agent-gui-feedback-loop.md | 53 +++++++ .../0003-web-agent-gui-feedback-loop.zh.md | 53 +++++++ docs/postmortem/README.i18n.yaml | 6 +- docs/postmortem/README.md | 1 + docs/postmortem/README.zh.md | 1 + scripts/dev-web.spec.ts | 42 ++++++ scripts/dev-web.ts | 87 +++++++----- tsconfig.host.json | 1 + 27 files changed, 438 insertions(+), 89 deletions(-) create mode 100644 apps/web/tests/hmr-live.e2e.ts create mode 100644 apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md create mode 100644 apps/web/tests/support/listen-probe.mjs create mode 100644 docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml create mode 100644 docs/postmortem/0003-web-agent-gui-feedback-loop.md create mode 100644 docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md create mode 100644 scripts/dev-web.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml index 22390d728f..1e22fa79fb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.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/bug-fix/2026-07-28-web-gui-feedback-loop.md -2026-07-28-web-gui-feedback-loop.md: 27295704d7b0cde3a46a6a28891545bfe31ed275 -2026-07-28-web-gui-feedback-loop.zh.md: 06367b09bd889bfb7a4052c46369720c4ec6d358 +2026-07-28-web-gui-feedback-loop.md: 039d2aebeeef903d10838a46b48e5172f0195126 +2026-07-28-web-gui-feedback-loop.zh.md: ddf748654788811aa19e2a8a295c3ff6df033fef diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md index 27295704d7..039d2aebee 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md @@ -8,11 +8,13 @@ English | [中文](2026-07-28-web-gui-feedback-loop.zh.md) The Web agent could identify neither the GUI hosting its session nor the URL the user was viewing. The [runtime-context decision](2026-07-28-web-agent-runtime-context.md) supplies the first fact, but a GUI edit still had no executable acceptance target: source edits, artifact builds, a listening process, and the user's existing page were unrelated observations. Repository affordances made a wrong substitute look valid because `apps/web/package.json` exposed `vite` as its `dev` script and bare Vite returned HTTP 200 even though it could not inject `window.__DSH_BOOT__`. -The incident session recorded three consecutive failures. After changing the theme, turn 2 delegated acceptance to the user with `pnpm run demo:tui` or an unspecified browser application and ran no assembled Web check. Turn 3 read the frontend package script, launched bare Vite on port 5173, treated HTTP 200 as readiness, and reported success; the user instead received the expected missing-`__DSH_BOOT__` white screen. Turn 4 found `dsh web`, rebuilt the shell, started an unmanaged shell-background process on port 3334, and checked only that the new page returned 200 with a boot manifest. It never probed the existing port 3081. In fact, the port-3081 process predated the build, and its static host read the rebuilt dist on the next request, so refreshing the original page already showed the change. Only after the user reported that fact did turn 5 inspect port 3081 and remove the redundant server. +The [incident post-mortem](../../../../docs/postmortem/0003-web-agent-gui-feedback-loop.md) owns the event-log timeline and why the original checks accepted the wrong page, process, and port. ## Decision -`dsh web` publishes one canonical loopback URL as both model-visible orientation and a managed shell fact. The `app:web-surface` prompt section says that unqualified references identify this GUI, names the URL, and defines acceptance as rebuilding the affected Web artifacts and verifying that existing URL after refresh. `DSH_WEB_URL` carries the same value into every foreground or managed background bash call, so the agent can query the target without parsing prose or process listings. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. +`dsh web` publishes one canonical loopback URL and its actual runtime mode as both model-visible orientation and managed shell facts. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` and `DSH_WEB_MODE=production|development` carry the same facts into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. + +The mode-specific prompt makes the agent, rather than the user, own the hidden startup contract. Production mode defines acceptance as rebuilding the affected artifacts and refreshing the existing URL. Development mode states that `dsh web --dev` activates only the HMR receiver: automatic client-plugin reload additionally requires a same-checkout `pnpm run dev:web` watcher, which the agent verifies before promising no-refresh updates. Shell and other plain-package changes still require rebuild plus refresh. An agent in production mode explains both commands when a user requests no-refresh updates; it does not launch a replacement GUI unless asked. The `apps/web` development script and Vite configuration reject serve mode before opening a port. Their diagnostics identify `apps/web` as a build-only shell, explain that only `dsh web` injects `window.__DSH_BOOT__`, and name the production and HMR entry paths. Vite build mode remains unchanged. @@ -20,7 +22,7 @@ No server restart or replacement is required merely because static artifacts cha ## Verification -The keyless fresh-round-trip browser scenario boots the shipped Web composition, drives a real replayed session, snapshots the URL-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` equals the scaffold's actual bound URL. A real Vite subprocess test requires serve mode to exit nonzero with the full-host correction. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, and HTTP bytes rather than an agent's success statement. +The keyless fresh-round-trip browser scenario boots the shipped production Web composition, drives a real replayed session, snapshots the URL/mode-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` and `$DSH_WEB_MODE` match the actual bound runtime. The real CLI smoke launches `dsh web --dev` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; the browser HMR scenario launches `dsh web --dev`, changes an initial production-roster bundle, and observes the new DOM under the same page identity. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, and HTTP bytes rather than an agent's success statement. ## Alternatives considered @@ -34,4 +36,4 @@ The keyless fresh-round-trip browser scenario boots the shipped Web composition, ## Consequences -Web prompts gain a dynamic URL paragraph, so provider prefix reuse now varies by bound port. Bash processes gain one non-secret managed environment variable. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the unsupported startup path fails before a white screen, and a second port can no longer masquerade as proof that the user's current page changed. +Web prompts gain a dynamic URL-and-mode paragraph, so provider prefix reuse now varies by bound port and mode. Bash processes gain two non-secret managed environment variables. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL/mode contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md index 06367b09bd..ddf7486547 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md @@ -8,19 +8,21 @@ Status: implemented Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道用户正在查看哪个 URL。[运行时上下文决策](2026-07-28-web-agent-runtime-context.md)提供前一项事实,但 GUI 编辑仍然没有可执行的验收目标:源码编辑、产物构建、监听中的进程与用户已打开的页面只是互不关联的观察结果。仓库提供的入口让错误的替代方案显得合理,因为 `apps/web/package.json` 将 `vite` 暴露为 `dev` 脚本,而裸 Vite 即使无法注入 `window.__DSH_BOOT__`,仍会返回 HTTP 200。 -事故会话记录了连续三次失败。修改主题后,第 2 轮把验收交给用户,要求用户运行 `pnpm run demo:tui` 或打开某个未指明的浏览器应用,自己没有执行任何真实组装的 Web 验证。第 3 轮读取前端包脚本,在 5173 端口启动裸 Vite,把 HTTP 200 当作就绪并报告成功;用户看到的却是符合预期的缺少 `__DSH_BOOT__` 的白屏。第 4 轮找到 `dsh web`,重新构建 Web 外壳,在 3334 端口启动了一个不受管理的 shell 后台进程,并且只检查新页面是否能返回 200 和启动 manifest(元数据清单),始终没有探测现有的 3081 端口。事实上,3081 端口的进程早于此次构建启动,其静态宿主会在下一次请求时读取重新构建的 dist,因此刷新原页面就已经能看到改动。直到用户报告这一事实,第 5 轮才检查 3081 端口并移除冗余服务。 +[事故复盘](../../../../docs/postmortem/0003-web-agent-gui-feedback-loop.md)记录事件日志时间线,以及原有检查为何会接受错误的页面、进程和端口。 ## 决策 -`dsh web` 发布一个规范的回环 URL,同时将其作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 系统提示词段说明:未加限定的指代指向此 GUI;该段会给出 URL,并把验收定义为重新构建受影响的 Web 产物,然后刷新并验证现有 URL。`DSH_WEB_URL` 会把同一个值传入每次前台或受管后台 bash 调用,使 agent 无需解析提示词或进程列表即可查询目标。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。 +`dsh web` 发布一个规范的回环 URL 及其实际运行时模式,同时将二者作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI,并给出 URL;`DSH_WEB_URL` 和 `DSH_WEB_MODE=production|development` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。 -`apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR(热模块替换)入口路径。Vite 构建模式保持不变。 +按模式区分的提示词让 agent 而非用户负责隐藏的启动契约。生产模式将验收定义为重新构建受影响的产物并刷新现有 URL。开发模式说明,`dsh web --dev` 只会启用 HMR(热模块替换)接收端:客户端插件要自动重新加载,还需要在同一检出中运行 `pnpm run dev:web` 监听进程,agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建并刷新。生产模式下的 agent 会在用户要求无需刷新即可更新时说明这两个命令;除非用户要求,否则不会启动替代 GUI。 + +`apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR 入口路径。Vite 构建模式保持不变。 静态产物发生变化时,不需要仅为此重启或替换服务器。宿主会在每次请求时读取 `index.html` 和静态资源,客户端 bundle 也会从当前文件提供,并设置 `no-cache`;因此,重新构建相关外壳与插件 bundle 后,刷新现有 URL 就是验收路径。启动另一个服务器只能证明另一个服务器可用。如果用户明确要求再启动一个长期运行的服务器,则现有受管后台任务契约负责其生命周期和完成通知;shell `&` 不能替代这套生命周期机制。 ## 验证 -无密钥的 fresh-round-trip 浏览器场景会启动已交付的 Web 组合,驱动真实的回放会话,对包含 URL 的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 等于测试脚手架实际绑定的 URL。真实 Vite 子进程测试要求服务模式以非零状态退出,并给出改用完整宿主的纠正信息。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出和 HTTP 字节,而不是 agent 的成功声明。 +无密钥的 fresh-round-trip 浏览器场景会启动已交付的生产 Web 组合,驱动真实的回放会话,对包含 URL 和模式的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 和 `$DSH_WEB_MODE` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web --dev` 并捕获模型提供方请求,从而固定完整的双命令开发契约。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle;浏览器 HMR 场景会启动 `dsh web --dev`,修改生产初始 roster 中的 bundle,并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。 ## 考虑过的替代方案 @@ -34,4 +36,4 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道 ## 影响 -Web 提示词会增加一个动态 URL 段落,因此模型提供方的前缀复用会随绑定端口变化。Bash 进程会增加一个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,不受支持的启动路径会在出现白屏前失败,另一个端口也无法再冒充用户当前页面已经改动的证据。 +Web 提示词会增加一个动态 URL 和模式段落,因此模型提供方的前缀复用会随绑定端口和模式变化。Bash 进程会增加两个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,agent 可以向用户说明实际承载其会话的进程究竟如何更新,不受支持的启动路径也会在出现白屏前失败。URL/模式契约会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index bd14674ff9..c82ea34050 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 3ec427a0bca501d70c9bca938692d6ef9557a2dd -README.zh.md: e0cf7cd399858822df613a98890c0872554a5085 +README.md: 22bcd3e7dc8fafdf5c9608ef56230b3bd62e4a80 +README.zh.md: c28989de2b19c172998c876574eeb893a3aa4f92 diff --git a/apps/cli/README.md b/apps/cli/README.md index 3ec427a0bc..22bcd3e7dc 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both tell the coding agent its model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL in both the prompt and `$DSH_WEB_URL`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. The Web host reads frontend dist and client bundles when requests arrive, so rebuilding the affected artifacts and refreshing the existing URL updates that GUI without replacing its process; bare `apps/web` Vite serving fails because it cannot inject `window.__DSH_BOOT__`. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both tell the coding agent its model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and plain-package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails because it cannot inject `window.__DSH_BOOT__`. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index e0cf7cd399..c28989de2b 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词和 `$DSH_WEB_URL` 中提供该进程的规范本地 URL;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。Web 宿主会在收到请求时读取前端 dist 和客户端 bundle,因此重新构建受影响的产物并刷新现有 URL 即可更新该 GUI,无须替换其进程;直接使用裸 `apps/web` Vite 服务会失败,因为它无法注入 `window.__DSH_BOOT__`。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下,宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle,因此刷新现有 URL 即可更新该 GUI,无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR(热模块替换)接收端,但要实现无刷新更新,还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundle;shell 和普通包(package)的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会失败,因为它无法注入 `window.__DSH_BOOT__`。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 9fd0f4d9bf..0ea3c7e21c 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -115,7 +115,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc web .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') - .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') + .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') .option('--workspace-root ', 'parent directory for name-created workspaces') .action((options: WebOptions) => { // Commander parses the parent (default-surface) options on either side of diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 97e5f94e18..a801929e16 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -18,13 +18,23 @@ const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) const DSH_WEB_URL = 'DSH_WEB_URL' as const +const DSH_WEB_MODE = 'DSH_WEB_MODE' as const + +type WebMode = 'production' | 'development' /** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ -function webSurfacePrompt(webUrl: string): string { +function webSurfacePrompt(webUrl: string, mode: WebMode): string { + const updateContract = mode === 'development' + ? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. ' + + 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. ' + + 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. ' + : 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. ' + + 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. ' return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. ` + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' + 'The browser provides no implicit DOM, route, or screenshot context. ' - + 'For changes to this GUI, rebuild the affected Web artifacts and verify this existing URL after a refresh; starting another server does not update this GUI. ' + + updateContract + + 'Starting another server does not update this GUI. ' + 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. ' + 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.' } @@ -37,20 +47,22 @@ function webSurfacePrompt(webUrl: string): string { * @param ctx - settled Web application context. * @param sourceRoot - absolute checkout root resolved from the launcher module. * @param webUrl - canonical loopback URL printed by this Web process. + * @param mode - whether this process mounted the client-plugin HMR receiver. */ -export function installWebPromptContext(ctx: Context, sourceRoot: string, webUrl: string): void { +export function installWebPromptContext(ctx: Context, sourceRoot: string, webUrl: string, mode: WebMode): void { const systemPrompt = ctx.get('systemPrompt') if (systemPrompt === undefined) throw new Error('dsh web: systemPrompt service missing after settled boot') const bashEnv = ctx.get('bashEnv') if (bashEnv === undefined) throw new Error('dsh web: bashEnv service missing after settled boot') addHarnessSourceSection(ctx, sourceRoot) - systemPrompt.section({ name: 'app:web-surface', order: -98, text: webSurfacePrompt(webUrl) }) + systemPrompt.section({ name: 'app:web-surface', order: -98, text: webSurfacePrompt(webUrl, mode) }) bashEnv.register({ name: 'web-runtime', variables: { [DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' }, + [DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' }, }, - resolve: () => ({ [DSH_WEB_URL]: webUrl }), + resolve: () => ({ [DSH_WEB_URL]: webUrl, [DSH_WEB_MODE]: mode }), }) } @@ -65,7 +77,7 @@ const ALL_INTERFACES_HOST = '0.0.0.0' * through only when the flag was given; absent, the `cordis.yml` value stands. * @param host - the bind host, or `undefined` to keep the config default. * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. - * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. + * @param dev - mount the client HMR receiver; `pnpm run dev:web` separately rebuilds watched plugin bundles. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. */ export async function runWeb( @@ -83,7 +95,8 @@ export async function runWeb( }) const { ctx, port: boundPort } = await entry.run() const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` - installWebPromptContext(ctx, SOURCE_ROOT, localUrl) + const mode: WebMode = dev ? 'development' : 'production' + installWebPromptContext(ctx, SOURCE_ROOT, localUrl, mode) let exiting = false const shutdown = (code: number): void => { diff --git a/apps/web/package.json b/apps/web/package.json index 722ebe5342..3c8f90b6a0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ }, "scripts": { "build": "vite build", - "dev": "node -e \"console.error('apps/web is build-only; run dsh web or dsh web --dev with pnpm run dev:web') ; process.exit(1)\"", + "dev": "vite", "watch": "vite build --watch" }, "license": "BSD-3-Clause", diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts new file mode 100644 index 0000000000..244dc0293d --- /dev/null +++ b/apps/web/tests/hmr-live.e2e.ts @@ -0,0 +1,132 @@ +/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */ + +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Fiber } from 'cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { REPO_ROOT } from './support.ts' + +function spawnSpec(argv: readonly string[], cwd: string, env?: Record): SubprocessSpawnSpec { + return { + argv, + cwd, + stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }, + graceMs: 5_000, + ...env === undefined ? {} : { env }, + } +} + +function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string): Promise { + return new Promise((resolveReady, reject) => { + let output = '' + let settled = false + const cleanup = (): void => { + clearTimeout(timer) + child.stdout?.off('data', onData) + child.stderr?.off('data', onData) + } + const resolveOnce = (value: string): void => { + if (settled) return + settled = true + cleanup() + resolveReady(value) + } + const rejectOnce = (error: Error): void => { + if (settled) return + settled = true + cleanup() + reject(error) + } + const onData = (chunk: Buffer): void => { + output += chunk.toString() + const match = pattern.exec(output) + if (match === null) return + resolveOnce(match[1] ?? match[0]) + } + const timer = setTimeout(() => { rejectOnce(new Error(`${label} not ready:\n${output}`)) }, 60_000) + child.stdout?.on('data', onData) + child.stderr?.on('data', onData) + void child.done.then((outcome) => { + rejectOnce(new Error(`${label} exited before ready (${JSON.stringify(outcome)}):\n${output}`)) + }, (error: unknown) => { + rejectOnce(new Error(`${label} failed before ready:\n${output}`, { cause: error })) + }) + }) +} + +async function stopTree(child: SubprocessHandle): Promise { + child.terminate() + const stopped = await child.waitForExit(AbortSignal.timeout(15_000)) + if (!stopped) throw new Error(`process tree ${String(child.pid)} did not stop after termination escalation`) + await child.done +} + +it('hot-reloads a real client-plugin source edit without refreshing the page', async () => { + const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-')) + const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx') + const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js') + const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js') + if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') + const originalSource = await readFile(sourcePath) + const originalBundle = await readFile(bundlePath) + const oldText = "Let's start building" + const sourceNeedle = 'Let's start building' + const newText = `HMR UPDATED ${'x'.repeat(80)}` + const updatedSource = originalSource.toString().replace(sourceNeedle, newText) + if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`) + + const subprocessCtx = new Context() + let subprocessFiber: Fiber | undefined + let watcher: SubprocessHandle | undefined + let host: SubprocessHandle | undefined + let browser: Awaited> | undefined + const failures: unknown[] = [] + try { + subprocessFiber = await subprocessCtx.plugin(LocalSubprocessService) + watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT)) + await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web') + host = subprocessCtx.subprocess.spawn(spawnSpec( + [process.execPath, binPath, 'web', '--dev', '--port', '0'], + world, + { + DEEPSEEK_API_KEY: 'keyless-hmr-no-call', + DSH_HOME: join(world, '.dsh'), + }, + )) + const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev') + browser = await chromium.launch() + const page = await browser.newPage() + const pageErrors: string[] = [] + page.on('pageerror', error => pageErrors.push(String(error))) + await page.goto(baseUrl, { waitUntil: 'load' }) + await page.getByText(oldText, { exact: true }).waitFor({ timeout: 15_000 }) + const pageIdentity = await page.evaluate(() => { + const identity = crypto.randomUUID() + Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity }) + return identity + }) + + await writeFile(sourcePath, updatedSource) + await page.getByText(newText, { exact: true }).waitFor({ timeout: 30_000 }) + expect(await page.evaluate(() => (window as Window & { __dshHmrPageIdentity?: string }).__dshHmrPageIdentity)) + .toBe(pageIdentity) + expect(pageErrors).toEqual([]) + } catch (error) { + failures.push(error) + } finally { + await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error)) + if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error)) + await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error)) + if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error)) + await browser?.close().catch((error: unknown) => failures.push(error)) + await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error)) + await rm(world, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + } + if (failures.length > 0) throw new AggregateError(failures, 'HMR browser test or cleanup failed') +}, 120_000) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index a6434cd0d1..e427d8def2 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -101,14 +101,14 @@ describe('web e2e: fresh round trip through the real assembly', () => { callId: CallId('web-url-probe'), name: 'bash', arguments: { - command: 'printf \'%s\\n\' "$DSH_WEB_URL"', - description: 'Print current Web URL', + command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"', + description: 'Print current Web runtime', }, agent, }) expect(result.isError).toBe(false) expect(result.content.filter(block => block.type === 'text').map(block => block.text).join('')) - .toBe(`${scaffold.baseUrl}\n`) + .toBe(`${scaffold.baseUrl}\nproduction\n`) }) it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 8c9ec1dd30..f6c6a106d4 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -208,7 +208,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { return new Promise((resolveReady, reject) => { let out = '' @@ -180,7 +182,7 @@ describe('dsh web keyless CLI smoke', () => { } }) - it('injects the invoking workspace AGENTS.md into the provider request', async () => { + it('routes --dev runtime context and workspace instructions through the real CLI request', async () => { requireDist() const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-')) mkdirSync(join(workspace, '.git')) @@ -212,7 +214,7 @@ describe('dsh web keyless CLI smoke', () => { const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href const child = spawn( process.execPath, - ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'], { cwd: workspace, env: { @@ -241,6 +243,10 @@ describe('dsh web keyless CLI smoke', () => { ]) const workspaceMessage = captured.messages?.find(message => message.role === 'user' && message.content?.includes('web-workspace-context-probe')) + const systemMessage = captured.messages?.find(message => message.role === 'system') + const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd() + .replace('{{webUrl}}', baseUrl) + expect(systemMessage?.content).toContain(expectedWebSection) expect(workspaceMessage).toMatchInlineSnapshot(` { "content": " diff --git a/apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md b/apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md index 352f164eb5..481b3d759c 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md @@ -2,6 +2,6 @@ You are an AI agent powered by the DeepSeek Harness SDK. Your own source code is the checkout at {{sourceRoot}}; you can read it there to learn how dsh works and how to extend it. -You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. For changes to this GUI, rebuild the affected Web artifacts and verify this existing URL after a refresh; starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL. +You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL. You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. diff --git a/apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md b/apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md new file mode 100644 index 0000000000..58157d4437 --- /dev/null +++ b/apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md @@ -0,0 +1 @@ +You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL. diff --git a/apps/web/tests/support/listen-probe.mjs b/apps/web/tests/support/listen-probe.mjs new file mode 100644 index 0000000000..c4708199c3 --- /dev/null +++ b/apps/web/tests/support/listen-probe.mjs @@ -0,0 +1,9 @@ +import { appendFileSync } from 'node:fs' +import { Server } from 'node:net' + +const marker = process.env.DSH_LISTEN_PROBE_MARKER +const listen = Server.prototype.listen +Server.prototype.listen = function (...args) { + if (marker !== undefined) appendFileSync(marker, 'listen\n') + return listen.apply(this, args) +} diff --git a/apps/web/tests/vite-entry.e2e.ts b/apps/web/tests/vite-entry.e2e.ts index 131526819f..1854cff948 100644 --- a/apps/web/tests/vite-entry.e2e.ts +++ b/apps/web/tests/vite-entry.e2e.ts @@ -1,7 +1,9 @@ /** Bare Vite must fail before it can present a bootless shell as a working GUI. */ -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { join } from 'node:path' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' import { createServer } from 'node:net' import { execa } from 'execa' import { describe, expect, it } from 'vitest' @@ -28,29 +30,34 @@ describe('Web development entry', () => { it('rejects the package dev alias with the full-host correction', async () => { const result = await execa('pnpm', ['run', 'dev'], { cwd: WEB_ROOT, reject: false }) expect(result.exitCode).not.toBe(0) - expect(result.stderr).toContain('apps/web is build-only') + expect(result.stderr).toContain('apps/web is not a standalone application') expect(result.stderr).toContain('dsh web') }) it('rejects the standalone Vite server with the full-host correction', async () => { + const probeRoot = mkdtempSync(join(tmpdir(), 'dsh-vite-listen-probe-')) + const marker = join(probeRoot, 'listen-called') const port = await freePort() - const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', String(port)], { - cwd: WEB_ROOT, - reject: false, - timeout: 10_000, - }) - expect(result.timedOut).toBe(false) - expect(result.exitCode).not.toBe(0) - expect(result.stderr).toContain('apps/web is not a standalone application') - expect(result.stderr).toContain('dsh web') - expect(result.stderr).toContain('window.__DSH_BOOT__') - await expect(new Promise((resolve, reject) => { - const probe = createServer() - probe.once('error', reject) - probe.listen(port, '127.0.0.1', () => probe.close((error) => { - if (error === undefined) resolve() - else reject(error) - })) - })).resolves.toBeUndefined() + try { + const probeModule = fileURLToPath(new URL('./support/listen-probe.mjs', import.meta.url)) + const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', String(port)], { + cwd: WEB_ROOT, + reject: false, + timeout: 10_000, + env: { + ...process.env, + DSH_LISTEN_PROBE_MARKER: marker, + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import ${pathToFileURL(probeModule).href}`.trim(), + }, + }) + expect(result.timedOut).toBe(false) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('apps/web is not a standalone application') + expect(result.stderr).toContain('dsh web') + expect(result.stderr).toContain('window.__DSH_BOOT__') + expect(existsSync(marker), 'Vite called Server.listen before rejecting standalone serve mode').toBe(false) + } finally { + rmSync(probeRoot, { recursive: true, force: true }) + } }) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 559e72618b..4b26ec3d9b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -31,6 +31,7 @@ "tests/settings-chrome.e2e.ts", "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", + "tests/hmr-live.e2e.ts", "tests/seeded-history.e2e.ts", "tests/code-mode-round.e2e.ts", "tests/cordis-tool-round.e2e.ts" diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml b/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml new file mode 100644 index 0000000000..838d489178 --- /dev/null +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.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 docs/postmortem/0003-web-agent-gui-feedback-loop.md +0003-web-agent-gui-feedback-loop.md: 13d13a607babfe7f5ddfdb6773c94f973bbef0db +0003-web-agent-gui-feedback-loop.zh.md: b3d35db9092304fcbca1106c0289fef441a2bad3 diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.md b/docs/postmortem/0003-web-agent-gui-feedback-loop.md new file mode 100644 index 0000000000..13d13a607b --- /dev/null +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.md @@ -0,0 +1,53 @@ +# Post-mortem 0003: Web agent validated a replacement server instead of its current GUI + +English | [中文](0003-web-agent-gui-feedback-loop.zh.md) + +Status: resolved + +## Executive summary + +A Web agent changed the GUI source but did not know which URL and process hosted its session. It delegated acceptance to the user, then treated a bare Vite HTTP 200 as success despite a missing `window.__DSH_BOOT__` white screen, and finally validated a replacement `dsh web` server on another port while the original page had already picked up rebuilt artifacts. The fix makes the current URL and runtime mode model-visible and shell-queryable, rejects standalone Vite before listen, and verifies production refresh and development HMR against external state. + +## Summary + +The session ran inside the DeepSeek Harness Web GUI at port 3081 while its selected Workspace was an empty `test/` directory. The model request named neither the GUI nor its source checkout, URL, process, or update mode. Repository affordances exposed `apps/web` with a Vite development script, while the full browser composition lived behind `dsh web`. + +The resulting actions were individually plausible but did not share one acceptance target. A source edit, a successful build, an HTTP 200, an injected boot manifest, and the user's existing page were treated as interchangeable facts. + +The evidence source is the persisted event log for `session-3eb796c2-5159-4686-affe-df8719f6f987`, whose header records cwd `/Users/tn.shen/Documents/deepseek-harness-gui-master/test`. Its initial request header is sequence 6; the user-facing handoff, bare-Vite launch, replacement-host launch, boot-manifest probe, and first 3081 process probe are sequences 30939, 31865, 34309, 34441, and 34681 respectively. The timeline below follows those events rather than reconstructing intent from the later report. + +## Impact + +The user had to identify three consecutive mistakes: acceptance was delegated back to them; the proposed preview was a blank page; and the reported successful URL was not the page they were using. An unmanaged replacement server also outlived the turn until the user challenged it. + +No change in this investigation restarted or modified the read-only 3081 and 3082 trial services. + +## Timeline + +- In turn 2, after editing the theme, the agent's sequence-30939 message told the user to run `pnpm run demo:tui` or open an unspecified Web application. It ran no assembled Web acceptance. +- In turn 3, the agent read `apps/web/package.json`, launched bare Vite on port 5173 at sequence 31865, observed HTTP 200, and declared success. The browser instead threw `client-modules: window.__DSH_BOOT__ is missing or not an object` and rendered a white page. +- In turn 4, the agent found the full `dsh web` path, rebuilt the shell, launched an unmanaged process on port 3334 at sequence 34309, and checked only that this replacement returned 200 with a boot manifest at sequence 34441. It never probed port 3081. +- In turn 5, the user reported at sequence 34556 that 3081 already showed the new theme. Only then, at sequence 34681, did the agent inspect the existing process and remove the redundant server. + +## Root cause + +The Web assembly had no model-visible identity for the current GUI, canonical URL, or runtime mode. The session cwd correctly represented the user's selected Workspace, but the model mistook that project boundary for the application boundary. No durable contract related the GUI source checkout, built artifacts, serving process, target origin, and browser acceptance. + +The wrong startup path looked legitimate because bare Vite returned HTTP 200. `window.__DSH_BOOT__` is injected only by the full host, so transport readiness did not imply application readiness. The first regression test repeated this mistake in another form: a timeout killed Vite and satisfied a nonzero-exit assertion. Live reproduction exposed that false positive. + +Background process semantics were also bypassed with shell `&`, so task identity, completion notices, collection, and cleanup did not apply. Verifying port 3334 therefore proved only that a second service worked. + +## Guardrails added + +- The Web launcher publishes the canonical loopback URL and actual production/development mode in the logged `app:web-surface` prompt section and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE` environment. +- Production guidance requires rebuilding artifacts and verifying the existing URL after refresh. Development guidance explains that `dsh web --dev` mounts only the HMR receiver; `pnpm run dev:web` in the same checkout must also rebuild client-plugin bundles, while shell and plain-package changes still require refresh. +- `apps/web` standalone Vite serve mode rejects during configuration. Its subprocess test proves natural exit and instruments `Server.listen()` so a transient bind cannot pass unnoticed. +- Layered real-path tests cover the CLI request, exact production/development prompts, shell runtime facts, same-port static replacement, source watcher rebuild, host stat polling, and browser HMR under an unchanged page identity. +- PR evidence preserves screenshots from the original 3081 session and a real-model before/after GUI run; external browser, HTTP, process, and session-log observations carry acceptance. + +## Lessons + +- The agent must know hidden runtime prerequisites before it can guide the user; startup mode is application context, not tribal knowledge. +- HTTP readiness, build success, and a boot manifest are different facts. Acceptance names the exact origin and externally observes the requested change there. +- A replacement service cannot prove that an existing page changed. Long-running processes use managed task lifecycles when they are actually requested. +- A regression test must be able to fail for the reported mechanism. Process timeout is not equivalent to fail-fast, and post-exit port availability does not prove the port was never bound. diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md b/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md new file mode 100644 index 0000000000..b3d35db909 --- /dev/null +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md @@ -0,0 +1,53 @@ +# 事故复盘(postmortem) 0003:Web agent(智能体)验收了替代服务器,而非其当前 GUI + +[English](0003-web-agent-gui-feedback-loop.md) | 中文 + +Status: resolved + +## 摘要 + +Web agent 修改了 GUI 源码,却不知道由哪个 URL 和进程承载当前会话。它把验收交还给用户,随后在 `window.__DSH_BOOT__` 缺失导致白屏的情况下,仍把裸 Vite 返回的 HTTP 200 当作成功;最后,原页面其实已经加载了重建产物,它却去验收另一个端口上的替代 `dsh web` 服务器。修复让当前 URL 和运行模式对模型可见且可由 shell 查询,在独立 Vite 开始监听前拒绝启动,并依据外部状态验收生产模式刷新与开发模式 HMR(热模块替换)。 + +## 概述 + +该会话运行在端口 3081 的 DeepSeek Harness Web GUI 中,而用户选择的 Workspace 是空的 `test/` 目录。模型请求既未指明该 GUI,也未提供它的源码检出目录、URL、进程或更新模式。仓库在 `apps/web` 中提供了 Vite 开发脚本,完整的浏览器组合则由 `dsh web` 提供。 + +由此产生的各个动作单看都合理,却没有指向同一个验收目标。源码修改、成功构建、HTTP 200、注入的启动 manifest(元数据清单)和用户原本打开的页面,被当成了可以相互替代的事实。 + +证据源是 `session-3eb796c2-5159-4686-affe-df8719f6f987` 的持久化事件日志,其头部记录的 cwd 为 `/Users/tn.shen/Documents/deepseek-harness-gui-master/test`。初始请求头位于序列 6;面向用户的交接、裸 Vite 启动、替代宿主启动、启动 manifest 探测,以及首次探测 3081 进程,分别位于序列 30939、31865、34309、34441 和 34681。下方时间线以这些事件为依据,而不是根据后续报告反推意图。 + +## 影响 + +用户不得不连续指出三个错误:agent 把验收交还给用户;建议预览的页面一片空白;报告成功的 URL 并不是用户正在使用的页面。一个不受管理的替代服务器还持续运行到下一轮,直到用户提出质疑。 + +本次调查没有重启或修改只读的 3081 和 3082 试验服务。 + +## 时间线 + +- 在第 2 轮中,agent 修改主题后,在序列 30939 的消息中让用户运行 `pnpm run demo:tui` 或打开一个未明确指定的 Web 应用。它没有对组装后的 Web 应用执行任何验收。 +- 在第 3 轮中,agent 读取 `apps/web/package.json`,在序列 31865 于端口 5173 上启动裸 Vite,观察到 HTTP 200 后便宣布成功。浏览器却抛出 `client-modules: window.__DSH_BOOT__ is missing or not an object`,并显示白屏。 +- 在第 4 轮中,agent 找到了完整的 `dsh web` 启动路径,重新构建 shell,在序列 34309 于端口 3334 上启动一个不受管理的进程,并且只在序列 34441 检查了这个替代服务是否返回 200 和启动 manifest。它从未探测端口 3081。 +- 在第 5 轮中,用户在序列 34556 报告 3081 已经显示新主题。直到序列 34681,agent 才检查既有进程并移除冗余服务器。 + +## 根因 + +Web 组合没有向模型提供当前 GUI、规范 URL 或运行模式的身份信息。会话 cwd 正确表示了用户选择的 Workspace,但模型误把这个项目边界当成了应用边界。系统也没有持久契约将 GUI 源码检出目录、构建产物、服务进程、目标 origin 和浏览器验收关联起来。 + +裸 Vite 返回 HTTP 200,使错误的启动路径看似合理。`window.__DSH_BOOT__` 只由完整宿主注入,因此传输层就绪不代表应用已就绪。首个回归测试以另一种方式重复了同样的错误:超时机制终止 Vite 后,非零退出断言仍会通过。真实复现暴露了这一误报。 + +agent 还通过 shell `&` 绕过了后台进程语义,因此任务身份、完成通知、结果收集和清理机制均未生效。验证端口 3334 只能证明第二个服务可以工作。 + +## 已添加的防护措施 + +- Web 启动器在记录到日志的 `app:web-surface` 提示词区段,以及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 环境变量中,发布规范环回 URL 和实际的生产/开发模式。 +- 生产模式指南要求重新构建产物,并在刷新后验证既有 URL。开发模式指南说明,`dsh web --dev` 只挂载 HMR 接收端;同一源码检出目录中的 `pnpm run dev:web` 还必须重新构建客户端插件 bundle,而 Web shell 和普通包的改动仍然需要刷新页面。 +- `apps/web` 的独立 Vite 服务模式会在配置阶段拒绝启动。其子进程测试验证进程自然退出,并插桩 `Server.listen()`,确保短暂绑定端口也不会漏检。 +- 分层的真实路径测试覆盖 CLI(命令行界面)请求、精确的生产/开发模式提示词、shell 运行时事实、同端口静态产物替换、源码 watcher 重建、宿主 stat 轮询,以及页面 identity 不变的浏览器 HMR。 +- PR(Pull Request)证据保留了原始 3081 会话的截图,以及真实模型驱动的 GUI 修改前后对比;验收以外部浏览器、HTTP、进程和会话日志的观测结果为准。 + +## 教训 + +- agent 必须先知道隐藏的运行时前置条件,才能指导用户;启动模式属于应用上下文,不应依赖团队口口相传。 +- HTTP 就绪、构建成功和启动 manifest 是不同的事实。验收必须明确指定确切的 origin,并从外部观察所请求的改动是否在该 origin 生效。 +- 替代服务无法证明既有页面已经改变。确实需要长时间运行的进程时,应使用受管的任务生命周期。 +- 回归测试必须能够针对所报告的机制失败。进程超时不等同于快速失败,进程退出后端口可用也不能证明该端口从未被绑定。 diff --git a/docs/postmortem/README.i18n.yaml b/docs/postmortem/README.i18n.yaml index e68d3a1a07..cbdfd07055 100644 --- a/docs/postmortem/README.i18n.yaml +++ b/docs/postmortem/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: df0e2fcb8540aeed005153dbecc451d781ca5ff1 -README.zh.md: 2ce6de475c705b02cd9dabfb2181929d81478e2c +# pnpm run verify-translation-pairing --write docs/postmortem/README.md +README.md: 4858f8841e92a895f2d1a840b59b42758e83d952 +README.zh.md: f2f69e44448df8e7016fbe5672a0c0d5a522c47a diff --git a/docs/postmortem/README.md b/docs/postmortem/README.md index df0e2fcb85..4858f8841e 100644 --- a/docs/postmortem/README.md +++ b/docs/postmortem/README.md @@ -14,3 +14,4 @@ Every post-mortem opens with an **Executive summary**: one short paragraph a bus |---|---| | [0001](0001-acp-default-export-drops-inject.md) | ACP server crashed on connect: `export default` dropped the plugin's `inject` | | [0002](0002-js-expression-disabled-filesystem-tools.md) | Filesystem snapshot tools were permanently disabled by a literal `!!js` object | +| [0003](0003-web-agent-gui-feedback-loop.md) | Web agent validated a replacement server instead of the GUI hosting its session | diff --git a/docs/postmortem/README.zh.md b/docs/postmortem/README.zh.md index 2ce6de475c..f2f69e4444 100644 --- a/docs/postmortem/README.zh.md +++ b/docs/postmortem/README.zh.md @@ -14,3 +14,4 @@ |---|---| | [0001](0001-acp-default-export-drops-inject.md) | ACP(Agent Client Protocol)服务器在连接时崩溃:`export default` 丢失了插件的 `inject` | | [0002](0002-js-expression-disabled-filesystem-tools.md) | 文件系统快照工具被一个字面量 `!!js` 对象永久禁用 | +| [0003](0003-web-agent-gui-feedback-loop.md) | Web agent 验证了替代服务器,而非承载其会话的 GUI | diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts new file mode 100644 index 0000000000..2edbc652ab --- /dev/null +++ b/scripts/dev-web.spec.ts @@ -0,0 +1,42 @@ +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import type { TsdownBundle } from 'tsdown' +import { watchClientPlugins } from './dev-web.ts' + +it('rebuilds a client-plugin bundle after its source changes', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-')) + let bundles: TsdownBundle[] = [] + try { + await symlink(join(import.meta.dirname, '..', 'node_modules'), join(root, 'node_modules'), 'dir') + await writeFile(join(root, 'package.json'), JSON.stringify({ name: '@dsh-test/dev-web-watch', private: true, type: 'module' })) + await writeFile(join(root, 'tsdown.config.ts'), ` +import { defineConfig } from 'tsdown' +export default defineConfig({ + entry: { client: 'src.ts' }, outDir: 'lib', format: 'cjs', platform: 'browser', dts: false, clean: false, + outputOptions: { entryFileNames: 'client.js' }, +}) +`) + const sourcePath = join(root, 'src.ts') + const bundlePath = join(root, 'lib/client.js') + await writeFile(sourcePath, 'export const version = "watch-v1"\n') + bundles = await watchClientPlugins(root, ['.'], 50) + await expect.poll(async () => { + try { + return (await readFile(bundlePath, 'utf8')).includes('watch-v1') + } catch { + return false + } + }, { timeout: 10_000 }).toBe(true) + + await new Promise(resolve => setTimeout(resolve, 1_000)) + await writeFile(sourcePath, `export const version = "watch-v2-${'x'.repeat(100)}"\n`) + await expect.poll(async () => (await readFile(bundlePath, 'utf8')).includes('watch-v2-'), { + timeout: 10_000, + }).toBe(true) + } finally { + for (const bundle of bundles) await bundle[Symbol.asyncDispose]() + await rm(root, { recursive: true, force: true }) + } +}, 20_000) diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index 38b1cffde1..aee7146487 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -18,9 +18,10 @@ * keys under each package's file config, and no package config defines it). */ import { globSync, readFileSync } from 'node:fs' -import { dirname, join, sep } from 'node:path' -import { fileURLToPath } from 'node:url' +import { dirname, join, resolve, sep } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' import { build } from 'tsdown' +import type { TsdownBundle } from 'tsdown' const repoRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -29,46 +30,64 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url)) * whose package.json carries `dshClient` with platform "web" is a client * plugin bundle emitter. Scanned once at startup — a package added while * watching means restarting this script. + * @param root - repository root containing the grouped package directories. * @returns workspace-relative plugin package directories. */ -function discoverPluginDirs(): string[] { +export function discoverPluginDirs(root = repoRoot): string[] { const dirs: string[] = [] - for (const manifestPath of globSync('packages/*/*/package.json', { cwd: repoRoot }).sort()) { - const manifest = JSON.parse(readFileSync(join(repoRoot, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } } + for (const manifestPath of globSync('packages/*/*/package.json', { cwd: root }).sort()) { + const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } } if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/')) } return dirs } -const PLUGIN_DIRS = discoverPluginDirs() -if (PLUGIN_DIRS.length === 0) { - console.error('dev-web: no dshClient (platform "web") packages found under packages/') - process.exit(1) +/** + * Start the tsdown watch build used by `pnpm run dev:web`. + * @param root - repository or fixture root passed to tsdown. + * @param pluginDirs - workspace-relative package directories to watch. + * @param pollInterval - optional source-watcher polling interval in milliseconds. + * @returns live bundles whose async disposers stop every watcher. + */ +export async function watchClientPlugins( + root: string, + pluginDirs: readonly string[], + pollInterval?: number, +): Promise { + return build({ + cwd: root, + workspace: [...pluginDirs], + watch: true, + ...pollInterval !== undefined + ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } } + : {}, + }) } -const args = process.argv.slice(2) -const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll=')) -if (args.some(a => a !== pollArg)) { - console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]') - process.exit(1) -} -const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500') -if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) { - console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`) - process.exit(1) -} +const invokedPath = process.argv[1] +const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href +if (isMain) { + const pluginDirs = discoverPluginDirs() + if (pluginDirs.length === 0) { + console.error('dev-web: no dshClient (platform "web") packages found under packages/') + process.exit(1) + } -await build({ - cwd: repoRoot, - workspace: PLUGIN_DIRS, - watch: true, - // Rolldown watch options ride through inputOptions (tsdown has no watcher - // tuning of its own); polling is opt-in for network mounts without inotify. - ...pollInterval !== undefined - ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } } - : {}, -}) -console.log( - `dev-web: watching ${String(PLUGIN_DIRS.length)} dshClient plugin packages` - + `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${PLUGIN_DIRS.join('\n ')}`, -) + const args = process.argv.slice(2) + const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll=')) + if (args.some(a => a !== pollArg)) { + console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]') + process.exit(1) + } + const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500') + if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) { + console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`) + process.exit(1) + } + + await watchClientPlugins(repoRoot, pluginDirs, pollInterval) + console.log( + `dev-web: watching ${String(pluginDirs.length)} dshClient plugin packages` + + `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${pluginDirs.join('\n ')}`, + ) +} diff --git a/tsconfig.host.json b/tsconfig.host.json index 9cf2a86bda..920f776876 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -18,6 +18,7 @@ "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", + "apps/web/tests/hmr-live.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", From 6cd9fefe88a978429cfc8582e8bec9a99102c0a6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:49:58 +0800 Subject: [PATCH 006/114] fix(cli): register web prompt context before boot --- ...-07-28-web-agent-runtime-context.i18n.yaml | 4 +-- .../2026-07-28-web-agent-runtime-context.md | 4 +-- ...2026-07-28-web-agent-runtime-context.zh.md | 4 +-- apps/cli/src/app-cli-entry.ts | 9 ++++-- apps/cli/src/web.ts | 21 +++++++------- apps/cli/tests/web-prompt-context.spec.ts | 29 +++++++++++++++++++ apps/web/tests/scaffold.ts | 4 +-- 7 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 apps/cli/tests/web-prompt-context.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.i18n.yaml index f49f2d8314..483bfd9e86 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.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/bug-fix/2026-07-28-web-agent-runtime-context.md -2026-07-28-web-agent-runtime-context.md: 8423f2d5542b7f9c841ecbf354fbea2e25699d13 -2026-07-28-web-agent-runtime-context.zh.md: cfb754bd92836142556638e9195050dfc282146e +2026-07-28-web-agent-runtime-context.md: 449c9d4ba2b144d02dee4b98ae80c86815aec5c1 +2026-07-28-web-agent-runtime-context.zh.md: def1674be5f193739bfb214a24f34590ee075d5f diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md index 8423f2d554..449c9d4ba2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md @@ -10,13 +10,13 @@ The shared CLI base configured an empty deployment persona, the Web overlay did ## Decision -The shared Web/headless overlay (`apps/cli/config/web.cordis.yml`) supplies a concise coding-agent persona containing the resolved `{{model}}` and session `{{cwd}}`. `dsh web` additionally resolves the harness checkout from the launcher's module URL, installs the existing `harness:source` section, and adds an `app:web-surface` section before serving requests. The [source-checkout/workdir decision](2026-07-30-source-checkout-workdir-distinction.md) owns the source section's wording and its warning not to infer one path from the other. +The shared Web/headless overlay (`apps/cli/config/web.cordis.yml`) supplies a concise coding-agent persona containing the resolved `{{model}}` and session `{{cwd}}`. `dsh web` additionally resolves the harness checkout from the launcher's module URL, installs the existing `harness:source` section, and adds an `app:web-surface` section before serving requests. The launcher registers that setup before mounting the config tree; its `systemPrompt` injection therefore installs both sections before later prompt consumers such as the agent loop can activate and emit a request header. The [source-checkout/workdir decision](2026-07-30-source-checkout-workdir-distinction.md) owns the source section's wording and its warning not to infer one path from the other. The Web section treats unqualified references to “this page,” “this GUI,” or “this app” as references to the DeepSeek Harness Web GUI. It also states that the browser provides no implicit DOM, route, or screenshot context, so the model can identify the product without claiming visual state it did not receive. The assembled text is logged in `request/header`, preserving the model-visible/logged invariant. ## Verification -The keyless fresh-round-trip Web scenario boots the shipped base plus Web overlay, installs the same launcher context as `dsh web`, runs a real session through the HTTP/SSE application, and snapshots the first four system-prompt sections with source and working-directory paths normalized. The snapshot pins the harness identity, source checkout, Web orientation, and resolved coding-agent persona in request order. +The focused startup-order test registers a later `systemPrompt` consumer and proves that it observes both launcher sections on its first activation. The keyless fresh-round-trip Web scenario boots the shipped base plus Web overlay, registers the same launcher context as `dsh web`, runs a real session through the HTTP/SSE application, and snapshots the first four system-prompt sections with source and working-directory paths normalized. The snapshot pins the harness identity, source checkout, Web orientation, and resolved coding-agent persona in request order. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md index cfb754bd92..def1674be5 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.zh.md @@ -10,13 +10,13 @@ CLI 共享 base 配置了空的部署 persona,Web overlay 没有替换它, ## 决策 -`apps/cli/config/web.cordis.yml` 这份 Web/无头共享 overlay 提供一段简洁的编码 agent persona,其中包含解析后的 `{{model}}` 与会话 `{{cwd}}`。`dsh web` 还会根据启动器模块的 URL 解析 harness checkout,安装现有的 `harness:source` 提示词段,并在对外提供请求服务前添加 `app:web-surface` 提示词段。源码提示词段的措辞,以及其中不得从一条路径推断另一条路径的警告,均由另行记录的[源码 checkout 与工作目录区分决策](2026-07-30-source-checkout-workdir-distinction.md)负责。 +`apps/cli/config/web.cordis.yml` 这份 Web/无头共享 overlay 提供一段简洁的编码 agent persona,其中包含解析后的 `{{model}}` 与会话 `{{cwd}}`。`dsh web` 还会根据启动器模块的 URL 解析 harness checkout,安装现有的 `harness:source` 提示词段,并在对外提供请求服务前添加 `app:web-surface` 提示词段。启动器会在挂载配置树前注册这项设置;因此,它的 `systemPrompt` 注入会在 agent loop(智能体循环)等后续提示词消费方激活并发出 request header 之前安装这两个提示词段。源码提示词段的措辞,以及其中不得从一条路径推断另一条路径的警告,均由另行记录的[源码 checkout 与工作目录区分决策](2026-07-30-source-checkout-workdir-distinction.md)负责。 Web 提示词段把未限定的「这个页面」「这个 GUI」或「这个应用」解释为 DeepSeek Harness Web GUI。同时,它会明确说明浏览器不会隐式提供 DOM、路由或截图上下文,使模型能够识别产品,但不会声称掌握未收到的视觉状态。组装后的文本会记录在 `request/header` 中,从而保持「模型可见内容必须有日志记录」这一不变量。 ## 验证 -无密钥的 Web fresh-round-trip 场景会启动已交付的 base 与 Web overlay,安装与 `dsh web` 相同的启动器上下文,并通过 HTTP/SSE 应用运行一个真实会话。测试会把源码路径和工作目录规范化,然后对系统提示词的前四个段落生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。 +聚焦启动顺序的测试会注册一个后续的 `systemPrompt` 消费方,并证明该消费方首次激活时就能观察到启动器的两个提示词段。无密钥的 Web fresh-round-trip 场景会启动已交付的 base 与 Web overlay,注册与 `dsh web` 相同的启动器上下文,并通过 HTTP/SSE 应用运行一个真实会话。测试会把源码路径和工作目录规范化,然后对系统提示词的前四个段落生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。 ## 考虑过的替代方案 diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 218df73530..7b182b4073 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -155,6 +155,8 @@ export interface AppCLIEntryOptions { workspaceRoot?: string /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */ trustedHosts?: string[] + /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ + prepare?: (ctx: Context) => Promise | void } /** @@ -180,8 +182,8 @@ export class AppCLIEntry { constructor(private readonly options: AppCLIEntryOptions) {} /** - * Run the boot chain: patch composition → Loader include boot (dev row - * before await) → fail-loud triple. + * Run the boot chain: patch composition → Loader installation → surface + * preparation → config-tree boot (dev row before await) → fail-loud triple. * @returns the settled root context and the listening port. */ async run(): Promise<{ ctx: Context; port: number }> { @@ -246,7 +248,7 @@ export class AppCLIEntry { if (telemetryPatch !== undefined) this.patches.push(telemetryPatch) } - /** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */ + /** Shared Loader boot; surface preparation precedes the tree, and the dev HMR row precedes the activation audit. */ private async bootTree(): Promise { // One include of the shared base with every overlay as a sibling patch // list: patches never cross an include boundary, so nesting them would @@ -260,6 +262,7 @@ export class AppCLIEntry { ...this.patches, ] this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => { + await this.options.prepare?.(ctx) if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) }) } diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 8510f74929..730ccdde93 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -23,17 +23,18 @@ const WEB_SURFACE_PROMPT = 'You are interacting with the user through the DeepSe + 'The browser provides no implicit DOM, route, or screenshot context.' /** - * Add the launcher-owned source location and Web-surface orientation after the - * shared config tree settles. The request header logs both sections with every - * model-visible prompt. - * @param ctx - settled Web application context. + * Register the launcher-owned source location and Web-surface orientation + * before the shared config tree mounts. The injection installs both sections + * when `systemPrompt` activates; because it precedes the Loader entries, later + * prompt consumers observe them on their first activation. + * @param ctx - Web root context with Loader installed but no config tree mounted. * @param sourceRoot - absolute checkout root resolved from the launcher module. */ -export function installWebPromptContext(ctx: Context, sourceRoot: string): void { - const systemPrompt = ctx.get('systemPrompt') - if (systemPrompt === undefined) throw new Error('dsh web: systemPrompt service missing after settled boot') - addHarnessSourceSection(ctx, sourceRoot) - systemPrompt.section({ name: 'app:web-surface', order: -98, text: WEB_SURFACE_PROMPT }) +export function prepareWebPromptContext(ctx: Context, sourceRoot: string): void { + ctx.inject(['systemPrompt'], (promptCtx) => { + addHarnessSourceSection(promptCtx, sourceRoot) + promptCtx.systemPrompt.section({ name: 'app:web-surface', order: -98, text: WEB_SURFACE_PROMPT }) + }) } // Display-only mirror of the webserver schema's loopback host: the address the @@ -65,13 +66,13 @@ export async function runWeb( overlayPath: WEB_OVERLAY, ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, dev, + prepare: (ctx) => { prepareWebPromptContext(ctx, SOURCE_ROOT) }, ...host !== undefined && { host }, ...port !== undefined && { port }, ...workspaceRoot !== undefined && { workspaceRoot }, ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() - installWebPromptContext(ctx, SOURCE_ROOT) let exiting = false const shutdown = (code: number): void => { diff --git a/apps/cli/tests/web-prompt-context.spec.ts b/apps/cli/tests/web-prompt-context.spec.ts new file mode 100644 index 0000000000..f19b691ddf --- /dev/null +++ b/apps/cli/tests/web-prompt-context.spec.ts @@ -0,0 +1,29 @@ +import { sep } from 'node:path' +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import { HARNESS_SOURCE_SECTION } from '@deepseek-ai/dsh-app-boot' +import { prepareWebPromptContext } from '../src/web.ts' + +describe('prepareWebPromptContext', () => { + it('installs both sections before a later systemPrompt consumer activates', async () => { + const ctx = new Context() + const sourceRoot = `${sep}opt${sep}harness-src` + let observedNames: string[] | undefined + try { + prepareWebPromptContext(ctx, sourceRoot) + const consumer = ctx.inject(['systemPrompt'], async (promptCtx) => { + const assembly = await promptCtx.systemPrompt.assemble() + observedNames = assembly.sections.map(section => section.name) + }) + + await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' }) + await consumer + + expect(observedNames).toContain(HARNESS_SOURCE_SECTION) + expect(observedNames).toContain('app:web-surface') + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index dfe342b5d4..226471227d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -53,7 +53,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' -import { installWebPromptContext } from '../../cli/src/web.ts' +import { prepareWebPromptContext } from '../../cli/src/web.ts' import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts' /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */ @@ -301,13 +301,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Sun, 2 Aug 2026 00:57:15 +0800 Subject: [PATCH 007/114] chore(bash): mark bashEnv ownership FIXME --- packages/bash/tool-bash/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 311548b520..3b3874bc59 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -354,6 +354,9 @@ const BACKGROUND_OUTPUT_PROPERTIES = { } as const export function apply(ctx: Context, config: Config = {}): void { + // FIXME(bash-env-ownership): Move ctx.bashEnv to a tool-independent shell + // environment plugin; replacing this tool with persistent Bash must not + // remove the managed DSH_* contributor seam. const bashEnv = new BashEnvRegistry(ctx, config) bashEnv.register({ name: 'session-persistence', From 52a715d3033b4a3a70d2495e282bbbd14b0f0af4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:13:58 +0800 Subject: [PATCH 008/114] fix(web): allow profiles without native bash env --- apps/cli/src/web.ts | 9 --------- apps/web/tests/scaffold.ts | 3 +-- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 712debfaf0..815a53a11d 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -82,14 +82,6 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: }) } -/** - * Fail a settled Web boot whose composition omitted the managed Bash environment registry. - * @param ctx - settled Web application context. - */ -export function assertWebRuntimeContext(ctx: Context): void { - if (ctx.get('bashEnv') === undefined) throw new Error('dsh web: bashEnv service missing after settled boot') -} - /** * Serve the browser UI from the shipped config tree. `host`/`port` are passed * through only when the flag was given; absent, the shipped Web overlay value stands. @@ -123,7 +115,6 @@ export async function runWeb( ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() - assertWebRuntimeContext(ctx) const resolvedLocalWebUrl = localWebUrl(ctx) let exiting = false diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 11c46d90e8..b53bcd4486 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -53,7 +53,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' -import { assertWebRuntimeContext, prepareWebRuntimeContext } from '../../cli/src/web.ts' +import { prepareWebRuntimeContext } from '../../cli/src/web.ts' import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts' /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */ @@ -318,7 +318,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Thu, 30 Jul 2026 03:11:16 +0800 Subject: [PATCH 009/114] fix(cordis): make config reload transactional --- ...-20-config-hot-reload-resilience.i18n.yaml | 6 +- ...2026-07-20-config-hot-reload-resilience.md | 31 +-- ...6-07-20-config-hot-reload-resilience.zh.md | 31 +-- docs/cordis-catalog/core/fiber.md | 6 +- docs/cordis-catalog/events.md | 3 +- .../stderr.expected.txt | 4 +- .../host/directory-picker-auto/src/index.ts | 8 +- .../tests/loader-composition.spec.ts | 2 +- packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 2 +- packages/host/webserver/README.zh.md | 2 +- .../host/webserver/tests/webserver.spec.ts | 24 +- packages/typert/loader/tests/loader.spec.ts | 4 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 6 +- packages/ui/app-boot/README.zh.md | 6 +- packages/ui/app-boot/package.json | 2 + packages/ui/app-boot/src/index.ts | 81 +++--- packages/ui/app-boot/tests/app-boot.spec.ts | 48 +++- .../ui/app-boot/tests/config-reload.spec.ts | 236 +++++++++++++++++- packages/ui/app-boot/tests/hmr-config.spec.ts | 142 +++++++++++ pnpm-lock.yaml | 6 + scripts/gen-cordis-catalog.ts | 3 +- vendor/README.md | 9 +- vendor/cordis/src/events.ts | 2 +- vendor/cordis/src/fiber.ts | 6 +- vendor/hmr/src/index.ts | 164 ++++++++++-- vendor/include/src/index.ts | 114 +++++---- vendor/loader/src/config/entry.ts | 192 +++++++++++--- vendor/loader/src/config/group.ts | 72 ++++-- vendor/loader/src/config/isolate.ts | 4 +- vendor/loader/src/config/tree.ts | 53 +++- vendor/loader/src/index.ts | 11 +- 33 files changed, 1020 insertions(+), 268 deletions(-) create mode 100644 packages/ui/app-boot/tests/hmr-config.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml index b16ef70d7c..f6a6429e64 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-20-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40 -2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md +2026-07-20-config-hot-reload-resilience.md: f3c36f8055179870c19c9d1ce99c3533fe602aa6 +2026-07-20-config-hot-reload-resilience.zh.md: 72ef2ebfa582dcc614198ed094c9b58ea1713460 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md index 1a8e29c603..f3c36f8055 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md @@ -6,33 +6,36 @@ English | [中文](2026-07-20-config-hot-reload-resilience.zh.md) ## Problem -The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones. +An invalid `cordis.yml` edit must not kill a running agent, but preserving the process is insufficient when a valid-looking update partially replaces the Loader tree before a later entry fails. Callers also need to observe a rejected live update without treating the same error as an unhandled boot failure. Personal configuration adds a second requirement: HMR must observe one exact file outside its module roots, including a file or parent directory created after startup. ## Decision -Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers: +The vendored Cordis lifecycle and Loader plugins provide an awaited, compensating config transaction, logged as local modifications 6, 8, and 9 in [vendor/README.md](../../../../vendor/README.md). -- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down. -- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged". -- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay. +`Fiber.update()` returns its `internal/update` waterfall result. Config validation remains synchronous, while the default continuation returns the restart promise. Loader entry updates can therefore distinguish validation, import, application, and rollback failure from successful lifecycle settlement. `EntryTree.await()` rechecks service-gated fibers after Loader tasks drain and rejects settled failures; a fiber waiting on an absent service remains a valid pending entry rather than making settlement hang. -Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`. +Loader imports a changed module name before disposing the active fiber. Candidate application is awaited; a failure disposes candidate effects and restores the prior plugin or config. Group reconciliation is sequential and restores earlier changed entries, additions, removals, and moves before rejecting. Persistence occurs only after successful programmatic mutation. This is a compensating transaction: lifecycle effects may be briefly visible, and a failed rollback is reported as an `AggregateError` rather than misrepresented as a retained tree. + +Include reads and validates detached candidate content, applies patches to a clone, reconciles the Loader tree, and only then commits cached content and parsed data. `refresh()` rejects to its caller after a parse, validation, application, or rollback failure. Initial load remains fail-loud; only an absent file may use `initial`. A non-array YAML/JSON result is invalid, and both file refresh and Include-config update re-apply patches without mutating the cached parse. + +HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` method watches one exact path from the nearest existing ancestor, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Both exact-path and ordinary config-file refreshes use that queue. A failure is normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed(filename, error)` event; rejecting observers are logged without stopping later refreshes. Creation, change, and removal are observed. ## Alternatives considered -**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include. +**Contain failures inside `Include.refresh()`.** Rejected because it prevents an HMR host from broadcasting the failure and still permits Loader reconciliation to hide partial application. Include owns candidate parsing and commit; HMR owns containment and observation. -**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place. +**Restart the process for every config edit.** Rejected because Cordis effects already provide reversible plugin lifecycle, and a syntax error or failed optional plugin must not discard live sessions merely to recover the prior composition. -**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file). +**Promise invisible atomic replacement.** Rejected because arbitrary plugin effects cannot be snapshotted. Sequential application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions. ## Consequences -- A bad `cordis.yml` edit now logs `ignoring config reload at ` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred. -- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base. -- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync. -- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree). +- A failed live refresh rejects internally, retains or restores the last-good tree when compensation succeeds, and broadcasts one typed failure without becoming an unhandled rejection. +- A rollback failure is visible and may leave an entry unavailable; the event and log do not claim otherwise. +- Fibers waiting on declared dependencies remain valid pending entries: lifecycle settlement means no current work failed, not that every dependency exists. +- Exact config watchers add filesystem resources only for registered paths and release them with their owning HMR fiber. +- The vendored Loader, Include, HMR, and core event typing diverge further from upstream; the complete divergence is maintained in the vendor manifest. ## Testing -`packages/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include. +`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, and `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md index 6c7a421bfa..72ef2ebfa5 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md @@ -6,33 +6,36 @@ Status: implemented ## Problem -各示例应用把 `@cordisjs/plugin-hmr` 挂载为叶子配置项,让运行中的 agent 能感知 `cordis.yml` 的编辑。一次错误的编辑就会杀死进程:`Include.refresh()` 把 YAML 解析错误原样抛出,HMR 的文件监听器在一个无人捕获的异步 chokidar 回调里 await `refresh()`,产生的未处理 rejection 触发 `dsh-app-boot` 的快速失败处理器——会话中途 `exit(1)`,正在运行的 TUI 就此丢失。另有两个相邻缺陷让*合法*的重载也出错:解析结果为 `undefined` 的文件(空文件或写入中途被截断的文件——编辑器和 `sed -i` 常态性地产生这类中间状态)会让配置项遍历直接崩溃,而不是被判定为无效文件;并且重新读取时从不重新应用 include 的 `config.patches`,因此对基于 overlay 的配置树(Code Mode、个人 overlay)做任何热重载,都会悄悄把打过补丁的配置项回退、并把插入的配置项移除。 +无效的 `cordis.yml` 编辑不得杀死运行中的 agent(智能体);但若一次看似有效的更新先部分替换 Loader 树,后续配置项才失败,仅仅保住进程仍不够。调用方还需要能观察到被拒绝的实时更新,同时不能让同一个错误被当作未处理的启动失败。个人配置还带来第二项要求:HMR(热模块替换)必须观察其模块根目录之外的一个确切文件,包括启动后才创建的文件或父目录。 ## Decision -加固 vendor 的 `@cordisjs/plugin-include`(在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 8 条),而不是修改调用方: +vendor 中的 Cordis 生命周期和 Loader 插件提供可等待、带补偿的配置事务,并在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 6、8、9 条。 -- `refresh()` await 整个「读取并更新」过程并捕获失败,记录一条警告,并保留上一份完好的配置树。热重载是尽力而为的;不变式是编辑器可能产生的任何文件状态都不得导致进程退出。 -- `read()` 对非数组的解析结果抛出 `TypeError`,把 `undefined` 解析结果并入同一个「无效文件」信号,并且只在解析成功后才提交 `content`/`data`——因此把编辑撤销回与上一份完好内容完全一致时,会正确地判定为「无变化」。 -- `refresh()` 与 `internal/update` 监听器在 `root.update()` 之前调用 `this.applyPatches(...)`,与 `[Service.init]` 保持一致。`applyPatches` 对缓存的解析结果做深拷贝(`structuredClone`)而不是就地修改,因此重复应用会收敛,移除补丁会回退到文件自身的值。监听器使用传入配置中的 `patches` 并自行持久化该配置:它否决 fiber 重启(子配置项就地更新),而 `Fiber.update` 只在 `next()` 之后才赋值 `this.config`,若不显式赋值,下一次重新读取会重新应用旧的 overlay。 +`Fiber.update()` 返回其 `internal/update` waterfall(瀑布式事件)的结果。配置校验保持同步,而默认 continuation 返回重启 promise。因此,Loader 配置项更新可以区分校验、导入、应用和回滚失败,以及生命周期成功完成。`EntryTree.await()` 会在 Loader 任务排空后重新检查受服务门控的 fiber,并在 fiber 已结算为失败时 reject;等待缺失服务的 fiber 仍是有效的 pending 配置项,不会让结算挂起。 -启动期行为保持快速失败并获得更准确的诊断:`[Service.init]` 只在 `ENOENT` 时回退到 `initial`(或「config file not found」);存在但无效的文件现在会以真实的解析错误失败,而不是被误标为文件缺失、或被 `initial` 静默覆盖。 +Loader 会先导入变化后的模块名,再 dispose(资源释放)活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect,并恢复先前的插件或配置。组内对账按顺序进行,并会在拒绝前恢复此前已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务:生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留。 + +Include 读取并校验尚未提交的候选内容,把补丁应用到其副本,对账 Loader 树,然后才提交缓存内容和解析数据。解析、校验、应用或回滚失败后,`refresh()` 会向调用方 reject。初始加载继续快速失败;只有文件不存在时才可以使用 `initial`。YAML/JSON 结果若不是数组即为无效;文件刷新和 Include 配置更新都会重新应用补丁,且不修改缓存的解析结果。 + +HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方法从最近的现有祖先目录开始监听一个确切路径,串行化并合并刷新,并返回一个异步 disposer;该 disposer 会关闭 watcher 并排空活跃工作。确切路径和普通配置文件的刷新都使用此队列。失败会被规范化为 `Error`、记入日志,并通过并行事件 `hmr/config-update-failed(filename, error)` 广播;发生 rejection 的观察者会被记录,但不会阻止后续刷新。创建、变更和移除均会被观察。 ## Alternatives considered -**在 HMR 监听回调里捕获,而不是在 `refresh()` 里。** 否决:这会让 `refresh()` 继续成为其他所有调用方的陷阱(`internal/update` 路径共享同一套树更新逻辑),而且无法修复 `undefined` 解析结果与补丁丢失这两个位于 include 内部的缺陷。 +**在 `Include.refresh()` 内收容失败。** 已否决,因为这会使 HMR 宿主无法广播失败,却仍允许 Loader 对账掩盖部分应用。Include 负责候选内容的解析与提交;HMR 负责收容和观察。 -**在 `installFailLoud` 里过滤配置文件相关的 rejection。** 否决:快速失败处理器的存在意义就是让延迟出现的加载失败可见;教它按来源给异常分类会悄悄吞掉真正的启动失败,并且原样保留陈旧 `data` 导致的崩溃。 +**每次编辑配置都重启进程。** 已否决,因为 Cordis effect 已经提供可逆的插件生命周期,而语法错误或可选插件失败不应只为恢复先前的组合就丢弃正在进行的会话。 -**用 PTY e2e 证明 TUI 能在错误编辑后存活。** 否决其作为主要门禁:PTY 冒烟测试读取仓库中已提交的 `cordis.yml`,就地破坏它对测试不安全,而临时副本无法解析该配置树的裸包说明符。单元测试直接驱动监听器所调用的 `refresh()` 入口;此外还对运行中的 TUI 做了人工验证(错误 YAML、空文件、恢复文件)。 +**承诺不可见的原子替换。** 已否决,因为任意插件 effect 无法制作快照。按顺序应用并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换。 ## Consequences -- 现在错误的 `cordis.yml` 编辑会记录 `ignoring config reload at `,agent 继续运行在上一份完好的配置树上;下一次合法编辑正常生效。TUI 示例没有挂载任何日志导出器,这条警告目前不会显示在屏幕上——在 TUI 中呈现 loader 警告的工作暂缓。 -- overlay 配置树在基础文件重载后补丁保持完整,不再悄悄回退到未打补丁的基础配置。 -- vendor 的 include 与上游进一步分叉;该分叉已记录在 vendor 的 manifest 里,下次同步时重新应用。 -- 已知缺口,不在本次范围内:HMR 监听器只处理 chokidar 的 `change` 事件,因此通过重命名替换文件的编辑方式(BSD `sed -i`、`git checkout`)完全不会触发配置重载;应用配置项重载后也不会可见地重启运行中的 TUI(未修改的代码树上即已如此)。 +- 实时刷新失败会在内部 reject;补偿成功时会保留或恢复上一份完好的树,并广播一次类型化失败,而不会成为未处理的 rejection。 +- 回滚失败可见,并可能使一个配置项不可用;事件和日志不会误称其已恢复。 +- 等待已声明依赖的 fiber 仍是有效的 pending 配置项:生命周期完成只表示当前工作均未失败,而不表示每项依赖都存在。 +- 确切配置 watcher 只为已注册路径增加文件系统资源,并随其所属 HMR fiber 一起释放。 +- vendor 中的 Loader、Include、HMR 与核心事件类型定义进一步偏离上游;全部分叉均维护在 vendor manifest(元数据清单)中。 ## Testing -`packages/ui/app-boot/tests/config-reload.spec.ts` 用真实 Loader 树加载临时配置并固定以下行为:无效 YAML 编辑和空文件编辑都让 `refresh()` 正常 resolve 而不产生 rejection,并保留之前的配置项配置;随后的合法编辑正常生效;overlay 配置树在重新读取时重新应用配置项补丁和插入的配置项;对 include 配置项自身 `patches` 的热更新立即生效、在下一次文件重读后依然保持、并在补丁移除后干净地回退。这些断言在未打补丁的 vendor include 上会失败。 +`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject;`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除。 diff --git a/docs/cordis-catalog/core/fiber.md b/docs/cordis-catalog/core/fiber.md index 3cca4e8b86..35a991f789 100644 --- a/docs/cordis-catalog/core/fiber.md +++ b/docs/cordis-catalog/core/fiber.md @@ -256,8 +256,8 @@ Dispose and immediately reload this plugin with its current config. * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns nothing; the restart runs behind the `internal/update` waterfall. - * @throws {ValidationError} when the new config fails validation. + * @returns the update waterfall result; the default restart returns a promise. + * @throws when validation, an update listener, or the restarted plugin fails. */ update(config: any, noSave = false) ``` @@ -269,7 +269,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o - `config` — the new raw config; validated before anything restarts. - `noSave` — hint for persistence hooks not to write the change back. -**Returns** nothing; the restart runs behind the `internal/update` waterfall. +**Returns** the update waterfall result; the default restart returns a promise. [Source](../../../vendor/cordis/src/fiber.ts#L734) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a7ff21e2f4..1f9386fe39 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -1183,7 +1183,8 @@ The framework events every plugin also sees, beyond the harness vocabulary above - `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:340`](../../vendor/cordis/src/events.ts)) - `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:342`](../../vendor/cordis/src/events.ts)) - `hmr/change` — A watched source file changed on disk. ([`vendor/hmr/src/index.ts:20`](../../vendor/hmr/src/index.ts)) -- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:21`](../../vendor/hmr/src/index.ts)) +- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:22`](../../vendor/hmr/src/index.ts)) +- `hmr/config-update-failed` — A watched config-file refresh failed. ([`vendor/hmr/src/index.ts:29`](../../vendor/hmr/src/index.ts)) - `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts)) - `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:24`](../../vendor/loader/src/index.ts)) - `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts)) diff --git a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt index 5896d03464..cd688cd471 100644 --- a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt +++ b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt @@ -1,3 +1,3 @@ -dsh-cli-demo: dsh-cli-demo: 1 entry did not activate -./activation-error.mjs: Error: startup activation snapshot failure +dsh-cli-demo: dsh-cli-demo: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry activation-error (./activation-error.mjs): startup activation snapshot failure +Error: startup activation snapshot failure at activation-error-fixture diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 5766e36b98..3cf75b20dc 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -61,11 +61,9 @@ export async function apply(ctx: Context): Promise { // nothing is left to unmount or await then. const entry = ctx.loader.store[id] if (entry === undefined) return - const fiber = entry.fiber - ctx.loader.remove(id) - // remove() only starts the fiber's dispose; join it so the chooser's - // unload signals completion only after the backend quiesced. - await fiber?.dispose() + // remove() disposes the entry transactionally, so the chooser's unload + // signals completion only after the backend quiesced. + await ctx.loader.remove(id) } }, 'directory-picker-auto: backend entry') } diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 59ca1c1992..9d0b8c7de8 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -167,7 +167,7 @@ describe('real Loader composition', () => { const { ctx, configPath } = await loadComposition('127.0.0.1') const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)! - ctx.loader.remove(backendEntry.id) + await ctx.loader.remove(backendEntry.id) const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() expect(entryNames(ctx)).not.toContain(NATIVE) diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 0160db9f01..a79958e9d2 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/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/host/webserver/README.md -README.md: ace8c09e43dd8544a28d300f97b04610be78bc69 -README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a +README.md: c3c7b222683bc7731a6c21f2fffd325225099bab +README.zh.md: 99c0560eb74dc8076772ba1deef3034000f5f0db diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index ace8c09e43..c3c7b22268 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -6,7 +6,7 @@ Plain HTTP route-registration plugin (default-exported `HttpServerService`, conf The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. -A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own. +A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own. In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index b9948e3d38..99c0560eb7 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -6,7 +6,7 @@ 该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 -监听失败(EADDRINUSE……)会从激活过程抛出,使 fiber 进入 FAILED 状态并由启动流程的快速失败扫描报告。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的响应(SSE)不会自行结束。 +监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的 SSE(Server-Sent Events)响应不会自行结束。 在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。 diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 017fedba1a..c64208eb8e 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context, FiberState } from 'cordis' +import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import HttpServer from '../src/index.ts' @@ -142,25 +142,17 @@ describe('real Loader composition', () => { const firstRoot = root root = undefined // keep the first composition's files until the end - // loader.await() never rejects (allSettled); the bind failure surfaces as - // a FAILED fiber whose error escapes as a late rejection — the shape the - // boot's installFailLoud is contracted to catch. Capture it here the same - // way, and assert it really is the bind error. - const rejections: unknown[] = [] - const onUnhandled = (err: unknown): void => { rejections.push(err) } - process.on('unhandledRejection', onUnhandled) let second: Context | undefined try { - second = await loadComposition(takenPort) - const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver') - expect(entry?.fiber?.state).toBe(FiberState.FAILED) - // The rejection escapes a tick after loader.await() settles; bounded poll. - for (let i = 0; i < 100 && rejections.length === 0; i++) { - await new Promise(resolve => setTimeout(resolve, 10)) + let failure: unknown + try { + await loadComposition(takenPort) + } catch (error) { + failure = error } - expect(rejections.map(String).join('\n')).toContain('EADDRINUSE') + second = context + expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/) } finally { - process.off('unhandledRejection', onUnhandled) await second?.fiber.dispose() context = first if (root !== undefined) await rm(root, { recursive: true, force: true }) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index c61db1ae9c..3b126f1e76 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -147,12 +147,12 @@ describe('typert loader', () => { await new Promise(resolve => setTimeout(resolve, 20)) expect(ctx.typert.list()).toHaveLength(1) - ctx.loader.remove(id) + await ctx.loader.remove(id) await ctx.loader.await() // The unmount reconciliation rides a queued microtask flush. await new Promise(resolve => setTimeout(resolve, 20)) expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined() - ctx.loader.remove(plainId) + await ctx.loader.remove(plainId) await ctx.loader.await() await new Promise(resolve => setTimeout(resolve, 20)) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 0ee7591bec..6d7aa6f0d3 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: efc8c42e192a02ecf37f8ea1356aa22261c70d0e -README.zh.md: 927d6d1fb493c404fcdbe14f1c668b1743412ea5 +README.md: e82d378f9cabd24d0f8b3069237f142c1885191f +README.zh.md: 5d749531e48a502291491e6d047b45cd8a505544 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index efc8c42e19..e82d378f9c 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,17 +8,17 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | -| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | +| `installFailLoud(binName, proc?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | -Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. +Loader settlement rejects import and lifecycle failures with the failing entry and stage; `boot()` disposes the partial context and wraps that failure with the bin name. Entries settlement leaves behind are audited separately: `assertEntriesLoaded` turns an enabled fiber-less entry into a rejection naming every unresolved plugin, and `assertEntriesActivated` awaits each failed fiber to include its original stack in the startup rejection and names each pending entry's unresolved services. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 927d6d1fb4..5d749531e4 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -8,17 +8,17 @@ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | -| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | +| `installFailLoud(binName, proc?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 +Loader 结算会在导入或生命周期失败时 reject,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber,把原始错误堆栈写入启动 rejection,并列出每个等待中配置项尚未解析的服务。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index ef267e8588..68062cb480 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -38,8 +38,10 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@cordisjs/plugin-hmr": "workspace:^", "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", + "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index febd724097..917eb777e8 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import Loader, { type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. @@ -430,12 +430,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * `cordis:include` builtin, loading through the ambient module pipeline * (vite/tsx/plain ESM) while the included tree's own specifiers stay * config-relative. The package build embeds Include while leaving Loader - * external, so the built include tree and host share one Loader peer. A - * missing fiber rejects here; a later init rejection is rethrown with its - * original stack by {@link assertEntriesActivated}; later unhandled - * rejections remain covered by {@link installFailLoud}. Built bins need the - * Loader's native helper for bare plugin specifiers; relative specifiers do - * not. + * external, so the built include tree and host share one Loader peer. Loader + * settlement rejects startup failures, which `boot` wraps after disposing the + * partial context; a missing fiber or never-activating entry is rejected by + * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's + * init rejection with its original stack; later unhandled rejections remain + * covered by {@link installFailLoud}. Built bins need the Loader's native + * helper for bare plugin specifiers; relative specifiers do not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). @@ -444,6 +445,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. + * @throws a labelled load error after disposing the partial context. */ export async function boot( binName: string, @@ -452,28 +454,49 @@ export async function boot( prepare?: (ctx: Context) => Promise | void, ): Promise { const ctx = new Context() - ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' - ctx.provide('dshHomePath', dshHomePath) - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await prepare?.(ctx) - await ctx.loader.create({ - name: 'cordis:include', - config: { - path: pathToFileURL(absoluteConfigPath).href, - ...patches !== undefined && patches.length > 0 ? { patches } : {}, - }, - }) - await ctx.loader.await() - // A surface can finish and dispose the whole tree while that await is still - // pending: the TUI renders as soon as its own fiber starts, so an `/exit` - // typed before the last entry settles tears the context down under us. The - // Loader service goes with it, and the activation audit describes a live - // tree — reading `ctx.loader` here would throw a TypeError over an app that - // exited exactly as asked. - if (ctx.get('loader') === undefined) return ctx - await assertEntriesActivated(ctx, binName) - return ctx + try { + ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' + ctx.provide('dshHomePath', dshHomePath) + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await prepare?.(ctx) + // Pinned id: the bootstrap include is app glue, not a config row, and its + // id appears in Loader failure chains — a random id would make startup + // diagnostics unstable across runs (and snapshot fixtures). + const rootInclude: EntryOptions = { + id: 'include', + name: 'cordis:include', + config: { + path: pathToFileURL(absoluteConfigPath).href, + ...patches !== undefined && patches.length > 0 ? { patches } : {}, + }, + } + await ctx.loader.create(rootInclude) + // A surface can finish and dispose the whole tree while startup is still + // in flight: the TUI renders as soon as its own fiber starts, so an `/exit` + // typed before the last entry settles tears the context down under us. The + // Loader service goes with it, and the activation audit describes a live + // tree — reading `ctx.loader` past this point would throw a TypeError over + // an app that exited exactly as asked. Transactional group updates settle + // lifecycle inside the mount, so the teardown can land before it returns; + // re-check after every await. + await ctx.get('loader')?.await() + if (ctx.get('loader') === undefined) return ctx + await assertEntriesActivated(ctx, binName) + return ctx + } catch (cause) { + await ctx.fiber.dispose() + const detail = cause instanceof Error ? cause.message : String(cause) + // The transactional Loader wraps a failing entry apply in one message per + // tree layer; every layer's message is folded into `detail` above, and the + // deepest cause is the plugin's own thrown error, whose stack names the + // real failure site — append it so the startup diagnostic preserves the + // original activation error instead of only the wrap chain. + let deepest: unknown = cause + while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause + const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' + throw new Error(`${binName}: plugin tree failed to load: ${detail}${stack}`, { cause }) + } } /** Prompt-section name for the harness-source location line an app bin adds after boot. */ diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 4f64365a64..03dd93a365 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -325,6 +325,22 @@ describe('boot', () => { } }) + it('disposes partial host setup and labels non-Error preparation failures', async () => { + const dir = tmp() + const failure = 42 + let disposed = false + const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => { + ctx.effect(() => () => { disposed = true }) + throw failure + }) + + await expect(task).rejects.toMatchObject({ + message: `${NAME}: plugin tree failed to load: ${failure}`, + cause: failure, + }) + expect(disposed).toBe(true) + }) + it('exposes dshHomePath to Loader config expressions', async () => { const dir = tmp() const dshHome = join(dir, 'home') @@ -375,7 +391,37 @@ describe('boot', () => { it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => { const dir = tmp() writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n') - await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`) + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow( + `${NAME}: plugin tree failed to load: failed to apply loader entry`, + ) + }) + + it('appends the deepest cause with its original stack to the load failure', async () => { + const dir = tmp() + writeFileSync(join(dir, 'failing.mjs'), [ + 'export function apply() {', + " const failure = new Error('pinned activation failure')", + " failure.stack = 'Error: pinned activation failure\\n at failing-fixture'", + ' throw failure', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n') + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([ + String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`, + String.raw`Error: pinned activation failure\n {4}at failing-fixture$`, + ].join(''))) + }) + + it('falls back to the deepest cause message when its stack was erased', async () => { + const dir = tmp() + const deepest = new Error('stackless deep failure') + delete (deepest as { stack?: string }).stack + await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => { + throw new Error('host preparation failed', { cause: deepest }) + })).rejects.toThrow( + `${NAME}: plugin tree failed to load: host preparation failed\nstackless deep failure`, + ) }) it('reports a pending real Loader fiber and the service unresolved in its own context', async () => { diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index c4f6c48d7f..1a236e906b 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -1,12 +1,7 @@ /** - * Config hot-reload resilience of the booted include tree. `dsh-app-boot` - * installs a fail-loud unhandled-rejection handler, so a `refresh()` that - * rethrows a config-file parse error would kill a live app on one bad - * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event - * callback nobody else catches). These tests pin the vendored - * `@cordisjs/plugin-include` contract that boot relies on: an invalid file - * keeps the last good tree, and a valid re-read re-applies overlay patches - * exactly like the initial load. + * Transactional config replacement through the booted Include and Loader tree. + * HMR contains rejected refreshes; direct callers receive the error after the + * previous generation has been retained or restored. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -15,6 +10,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import type { Context } from 'cordis' import type { Include } from '@cordisjs/plugin-include' +import { Group } from '@cordisjs/plugin-loader' import { boot } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -27,9 +23,10 @@ interface TreeFixture { include: Include } -async function bootTree(configBody: string): Promise { +async function bootTree(configBody: string, files: Record = {}): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-')) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) + for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content) writeFileSync(join(dir, 'cordis.yml'), configBody) const ctx = await boot(NAME, join(dir, 'cordis.yml')) const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined) @@ -41,20 +38,41 @@ function entryConfig(ctx: Context, id: string): unknown { return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config } +function entryById(ctx: Context, id: string) { + const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id) + if (!entry) throw new Error(`missing loader entry ${id}`) + return entry +} + +function plugin(name: string, body = ''): string { + return `export default function ${name}(_ctx, config = {}) { ${body} }\n` +} + +async function expectUpdateFailure(task: Promise, stage: string): Promise { + try { + await task + } catch (error) { + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain(`failed to ${stage} loader entry`) + return + } + throw new Error(`expected loader update to fail during ${stage}`) +} + describe('include refresh with an invalid file', () => { - it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => { + it('rejects while keeping the last good tree, then applies the next valid edit', async () => { const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n') try { expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n') - await expect(include.refresh()).resolves.toBeUndefined() + await expect(include.refresh()).rejects.toThrow('failed to parse config file') expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) // An empty file parses to `undefined` without a YAML error; it must be // treated exactly like a parse failure, not crash the entry walk. writeFileSync(join(dir, 'cordis.yml'), '') - await expect(include.refresh()).resolves.toBeUndefined() + await expect(include.refresh()).rejects.toThrow('failed to validate config file') expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n') @@ -67,6 +85,200 @@ describe('include refresh with an invalid file', () => { }) }) +describe('loader entry replacement', () => { + it('imports a changed name before replacing the running plugin', async () => { + const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { + 'old.mjs': plugin('oldPlugin'), + 'new.mjs': plugin('newPlugin'), + }) + try { + const entry = entryById(ctx, 'target') + await entry.update({ name: './new.mjs' }) + expect(entry.options.name).toBe('./new.mjs') + expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options) + expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin') + expect(entry.options.disabled).toBeUndefined() + await entry.fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('retains the running plugin when the replacement cannot be imported', async () => { + const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { + 'old.mjs': plugin('oldPlugin'), + }) + try { + const entry = entryById(ctx, 'target') + const fiber = entry.fiber + await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import') + expect(entry.options.name).toBe('./old.mjs') + expect(entry.fiber === fiber).toBe(true) + await fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores the previous plugin after replacement application fails', async () => { + const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { + 'old.mjs': plugin('oldPlugin'), + 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'), + }) + try { + const entry = entryById(ctx, 'target') + const previous = entry.fiber + await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply') + expect(entry.options.name).toBe('./old.mjs') + expect(entry.fiber === previous).toBe(false) + expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin') + expect(entry.options.disabled).toBeUndefined() + await entry.fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores the previous config when an in-place restart fails', async () => { + const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', { + 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), + }) + try { + const entry = entryById(ctx, 'target') + const fiber = entry.fiber + await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply') + expect(entry.options.config).toEqual({ fail: false }) + expect(entry.fiber === fiber).toBe(true) + await fiber?.await() + } finally { + await ctx.fiber.dispose() + } + }) + + it('does not persist a failed direct fiber update', async () => { + const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', { + 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), + }) + try { + const entry = entryById(ctx, 'target') + const fiber = entry.fiber + if (!fiber) throw new Error('target entry has no fiber') + await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed') + expect(entry.options.config).toEqual({ fail: false }) + expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options) + } finally { + await ctx.fiber.dispose() + } + }) +}) + +describe('loader tree replacement', () => { + it('rolls back earlier updates and additions when a later entry fails', async () => { + const { ctx, dir, include } = await bootTree([ + '- id: existing', + ' name: ./configurable.mjs', + ' config:', + ' value: old', + '', + ].join('\n'), { + 'configurable.mjs': plugin('configurablePlugin'), + 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'), + }) + try { + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: existing', + ' name: ./configurable.mjs', + ' config:', + ' value: candidate', + '- id: added', + ' name: ./noop.mjs', + '- id: bad', + ' name: ./bad.mjs', + '', + ].join('\n')) + await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad') + expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' }) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false) + + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: existing', + ' name: ./configurable.mjs', + ' config:', + ' value: committed', + '- id: added', + ' name: ./noop.mjs', + '', + ].join('\n')) + await include.refresh() + expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' }) + expect(entryById(ctx, 'added').fiber).toBeDefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => { + const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n') + ctx.loader.builtins.group = Group + try { + const config = (disabled: boolean) => [ + '- id: parent', + ' name: cordis:group', + ' group: true', + ` disabled: ${disabled}`, + ' config:', + ' - id: child', + ' name: ./noop.mjs', + '', + ].join('\n') + + writeFileSync(join(dir, 'cordis.yml'), config(false)) + await include.refresh() + expect(entryById(ctx, 'child').fiber).toBeDefined() + + writeFileSync(join(dir, 'cordis.yml'), config(true)) + await include.refresh() + expect(entryById(ctx, 'child').fiber).toBeUndefined() + + writeFileSync(join(dir, 'cordis.yml'), config(false)) + await include.refresh() + expect(entryById(ctx, 'child').fiber).toBeDefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('restores a programmatic entry move when its update fails', async () => { + const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', { + 'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), + }) + ctx.loader.builtins.group = Group + try { + const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] }) + const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } }) + const target = entryById(ctx, targetId) + const source = target.parent + const sourceIndex = source.data.indexOf(target.options) + const destination = entryById(ctx, groupId).subgroup + if (!destination) throw new Error('created loader group has no subgroup') + + await expectUpdateFailure( + ctx.loader.update(targetId, { config: { fail: true } }, groupId), + 'apply', + ) + + expect(target.parent).toBe(source) + expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx) + expect(source.data.indexOf(target.options)).toBe(sourceIndex) + expect(destination.data).not.toContain(target.options) + expect(target.options.config).toEqual({ fail: false }) + } finally { + await ctx.fiber.dispose() + } + }) +}) + describe('include refresh with overlay patches', () => { it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-')) diff --git a/packages/ui/app-boot/tests/hmr-config.spec.ts b/packages/ui/app-boot/tests/hmr-config.spec.ts new file mode 100644 index 0000000000..1892a6e73a --- /dev/null +++ b/packages/ui/app-boot/tests/hmr-config.spec.ts @@ -0,0 +1,142 @@ +import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Hmr from '@cordisjs/plugin-hmr' +import Loader from '@cordisjs/plugin-loader' +import Timer from '@cordisjs/plugin-timer' +import { describe, expect, it } from 'vitest' + +async function bootHmr(dir: string): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(dir).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + return ctx +} + +async function eventually(test: () => boolean, message: string): Promise { + const deadline = Date.now() + 10_000 + while (!test()) { + if (Date.now() >= deadline) throw new Error(message) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +describe('HMR exact config paths', () => { + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + const observed: string[] = [] + try { + await ctx.hmr.registerConfig(filename, () => { + try { + observed.push(readFileSync(filename, 'utf8')) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + observed.push('missing') + } + }) + + writeFileSync(filename, 'one', { flag: 'wx' }) + await eventually(() => observed.includes('one'), 'HMR did not observe config creation') + writeFileSync(filename, 'two') + await eventually(() => observed.includes('two'), 'HMR did not observe config change') + unlinkSync(filename) + await eventually(() => observed.includes('missing'), 'HMR did not observe config removal') + } finally { + await ctx.fiber.dispose() + } + }) + + it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const dir = join(root, 'later') + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(root) + const observed: string[] = [] + try { + await ctx.hmr.registerConfig(filename, () => { + observed.push(readFileSync(filename, 'utf8')) + }) + mkdirSync(dir) + writeFileSync(filename, 'created') + await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent') + } finally { + await ctx.fiber.dispose() + } + }) + + it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const filename = join(dir, 'plugins.yml') + writeFileSync(filename, 'one') + const ctx = await bootHmr(dir) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const observed: string[] = [] + let active = 0 + let maxActive = 0 + try { + const dispose = await ctx.hmr.registerConfig(filename, async () => { + active += 1 + maxActive = Math.max(maxActive, active) + observed.push(readFileSync(filename, 'utf8')) + if (observed.length === 1) { + started.resolve(undefined) + await release.promise + } + active -= 1 + }) + await started.promise + writeFileSync(filename, 'two') + // Chokidar coalesces atomic writes for 100 ms by default. Wait beyond + // that window so this edit is queued before registration disposal. + await new Promise(resolve => setTimeout(resolve, 250)) + + let disposed = false + const disposal = dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + release.resolve(undefined) + await disposal + expect(maxActive).toBe(1) + expect(observed).toEqual(['one', 'two']) + } finally { + release.resolve(undefined) + await ctx.fiber.dispose() + } + }) + + it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + const failure = Promise.withResolvers<{ filename: string; error: Error }>() + let failureCount = 0 + try { + ctx.on('hmr/config-update-failed', () => { + throw new Error('observer failed') + }) + ctx.on('hmr/config-update-failed', (failedFilename, error) => { + failureCount += 1 + failure.resolve({ filename: failedFilename, error }) + }) + await ctx.hmr.registerConfig(filename, () => { throw 42 }) + writeFileSync(filename, 'invalid') + + const observed = await failure.promise + expect(observed.filename).toBe(filename) + expect(observed.error).toBeInstanceOf(Error) + expect(observed.error.message).toBe('42') + + writeFileSync(filename, 'invalid again') + await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected') + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34eb7b8f87..dff92c6511 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5484,12 +5484,18 @@ importers: specifier: ^4.2.0 version: 4.2.0 devDependencies: + '@cordisjs/plugin-hmr': + specifier: workspace:^ + version: link:../../../vendor/hmr '@cordisjs/plugin-include': specifier: workspace:^ version: link:../../../vendor/include '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@cordisjs/plugin-timer': + specifier: workspace:^ + version: link:../../../vendor/timer '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index dc5c392549..97685a9086 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -303,7 +303,8 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = { { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' }, { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' }, { name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' }, - { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' }, + { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:22' }, + { name: 'hmr/config-update-failed', summary: 'A watched config-file refresh failed.', source: 'vendor/hmr/src/index.ts:29' }, { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' }, { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' }, { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' }, diff --git a/vendor/README.md b/vendor/README.md index 2d3e1b6b05..1ad2b94e41 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -35,11 +35,12 @@ Keep this log exhaustive — every divergence from upstream must be listed. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. -6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. +6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. -9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. -10. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. +8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially, undo earlier changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. +10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. +11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. ## Sync procedure diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 7831fa75d1..2e862c97d4 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -334,7 +334,7 @@ export interface Events { /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ - 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void + 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise): void | Promise /** Waterfall: a service is being read through the context proxy. */ 'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any /** Waterfall: a service is being written through the context proxy. */ diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 61de8bed04..5511b39036 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -728,13 +728,13 @@ export class Fiber { * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns nothing; the restart runs behind the `internal/update` waterfall. - * @throws {ValidationError} when the new config fails validation. + * @returns the update waterfall result; the default restart returns a promise. + * @throws when validation, an update listener, or the restarted plugin fails. */ update(config: any, noSave = false) { this.assertActive() config = resolveConfig(this.runtime!, config) - this.context.waterfall(this, 'internal/update', config, noSave, () => { + return this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined return this.restart() diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 9727580efd..65ce923dc3 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -1,9 +1,10 @@ -import { Context, Inject, Service, type Plugin } from 'cordis' +import { Context, Service, type Plugin } from 'cordis' import type { Dict } from 'cosmokit' import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plugin-loader' import type { Include } from '@cordisjs/plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' -import { relative, resolve } from 'node:path' +import { dirname, relative, resolve } from 'node:path' +import { stat } from 'node:fs/promises' import { handleError } from './error.ts' import type {} from '@cordisjs/plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -19,6 +20,13 @@ declare module 'cordis' { interface Events { 'hmr/change'(url: string): void 'hmr/reload'(reloads: Map): void + /** + * A watched config-file refresh failed. + * @param filename - Absolute path observed by HMR. + * @param error - Normalized refresh failure. + * @mode parallel + */ + 'hmr/config-update-failed'(filename: string, error: Error): Promise | void } } @@ -44,13 +52,42 @@ interface Reload { runtime?: Plugin.Runtime } -@Inject('loader') -@Inject('timer') +interface ConfigRefresh { + dirty: boolean + running?: Promise +} + +interface ConfigRegistration { + watcher: FSWatcher +} + +async function findWatchRoot(filename: string): Promise<{ root: string; depth: number }> { + let root = dirname(filename) + let depth = 0 + while (true) { + try { + if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`) + return { root, depth } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = dirname(root) + if (parent === root) throw error + root = parent + depth += 1 + } + } +} + class Hmr extends Service { + static inject = ['loader', 'timer'] + public baseDir: string private internal: ModuleLoader private watcher!: FSWatcher + private readonly configs = new Map() + private readonly configRefreshes = new WeakMap() + private readonly refreshTasks = new Set>() /** * Changes from externals will always trigger a full reload. @@ -82,6 +119,65 @@ class Hmr extends Service { this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl)) } + /** + * Watch one exact config path outside the configured module roots. + * @param filename - Config path, resolved against the HMR base directory. + * @param refresh - Refresh callback run serially on add, change, or unlink. + * @returns an asynchronous disposer once the exact watch is ready. + * @throws when HMR is inactive, the path is already registered, or watcher startup fails. + */ + async registerConfig(filename: string, refresh: () => Promise | void): Promise<() => Promise> { + if (!this.watcher) throw new Error('HMR is not active') + filename = resolve(this.baseDir, filename) + if (this.configs.has(filename)) throw new Error(`config path already registered: ${filename}`) + + const { root, depth } = await findWatchRoot(filename) + const watcher = watch(root, { + ...this.config, + cwd: undefined, + depth, + ignored: undefined, + ignoreInitial: false, + }) + const registration = { watcher } + this.configs.set(filename, registration) + const onChange = (path: string) => { + if (resolve(path) !== filename) return + this.refreshConfig(registration, filename, refresh) + } + watcher.on('add', onChange) + watcher.on('change', onChange) + watcher.on('unlink', onChange) + + const ready = Promise.withResolvers() + let readyState: 'pending' | 'resolved' | 'rejected' = 'pending' + watcher.once('ready', () => { + readyState = 'resolved' + ready.resolve() + }) + watcher.on('error', (error) => { + if (readyState === 'pending') { + readyState = 'rejected' + ready.reject(error) + } else { + this.ctx.logger.warn(error) + } + }) + + try { + await ready.promise + return this.ctx.effect(() => async () => { + if (this.configs.get(filename) === registration) this.configs.delete(filename) + await watcher.close() + await this.configRefreshes.get(registration)?.running + }, 'hmr.registerConfig()') + } catch (error) { + this.configs.delete(filename) + await watcher.close() + throw error + } + } + /** * Resolve a module specifier to a URL, compatible with Node 22-24. */ @@ -93,7 +189,12 @@ class Hmr extends Service { } async* [Service.init]() { - yield () => this.watcher?.close() + yield async () => { + await this.watcher?.close() + await Promise.allSettled([...this.configs.values()].map(registration => registration.watcher.close())) + this.configs.clear() + await Promise.allSettled([...this.refreshTasks]) + } const { loader } = this.ctx const { root, ignored } = this.config @@ -122,9 +223,18 @@ class Hmr extends Service { const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce) - this.watcher.on('change', async (path) => { - this.ctx.logger.debug('change detected at %C', path) + const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => { + this.ctx.logger.debug('%s detected at %C', kind, path) const filename = resolve(this.baseDir, path) + // Config reload: the file is a loader config file (e.g. cordis.yml). + for (const entry of loader.entries()) { + const include = entry.subtree as Include | undefined + if (include?.filename !== filename) continue + this.refreshConfig(include, filename, () => include.refresh()) + return + } + + if (kind !== 'change') return const url = pathToFileURL(filename).href // Full reload: the changed file is part of the framework @@ -138,16 +248,40 @@ class Hmr extends Service { return partialReload() } - // Config reload: the file is a loader config file (e.g. cordis.yml) - for (const entry of this.ctx.loader.entries()) { - const include = entry.subtree as Include | undefined - if (include?.filename !== filename) continue - await include.refresh() - return - } - this.ctx.emit('hmr/change', url) + } + this.watcher.on('add', path => onChange('add', path)) + this.watcher.on('change', path => onChange('change', path)) + this.watcher.on('unlink', path => onChange('unlink', path)) + } + + private refreshConfig(key: object, filename: string, refresh: () => Promise | void) { + const state = this.configRefreshes.get(key) ?? { dirty: false } + this.configRefreshes.set(key, state) + state.dirty = true + if (state.running) return + const task = (async () => { + do { + state.dirty = false + try { + await refresh() + } catch (reason) { + const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason }) + this.ctx.logger.warn('config reload at %C failed', filename) + this.ctx.logger.warn(error) + try { + await this.ctx.parallel('hmr/config-update-failed', filename, error) + } catch (rejection) { + this.ctx.logger.warn(rejection) + } + } + } while (state.dirty) + })().finally(() => { + state.running = undefined + this.refreshTasks.delete(task) }) + state.running = task + this.refreshTasks.add(task) } // hide stack trace from HMR diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 29c894401c..43860dfd56 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -35,7 +35,8 @@ const supported = new Set(Object.keys(writable)) * Apply patch lists to an entry list — THE patch semantics of this include, * shared by mounting (`applyPatches`) and offline config tooling * (`dsh --dump-config`) so a dump can never drift from what boots. The input - * is never mutated: patching shared entry objects would bake earlier patch + * is never mutated and the result is always detached from it (even with no + * patches): patching or mounting shared entry objects would bake earlier * values into the cached parse, so repeated application (config hot-reloads) * could never revert a removed or changed patch. Inserted entries are indexed * as they are added, so a later patch in the same list can target a row an @@ -50,8 +51,8 @@ export function applyEntryPatches( patches: PatchOptions[] | undefined, warn: (message: string, ...args: any[]) => void, ): EntryOptions[] { - if (!patches?.length) return [...data] data = structuredClone(data) + if (!patches?.length) return data const entryMap = new Map() const buildMap = (entries: EntryOptions[]) => { @@ -117,6 +118,20 @@ export function applyEntryPatches( return data } +type ConfigUpdateStage = 'read' | 'parse' | 'validate' + +interface ReadCandidate { + content: string + data: EntryOptions[] +} + +class ConfigFileError extends Error { + constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) { + super(`failed to ${stage} config file ${path}`, { cause }) + this.name = 'ConfigFileError' + } +} + /** Runtime patch applied to entries loaded from an included config file. */ export interface PatchOptions { id?: string @@ -169,17 +184,11 @@ export class Include extends EntryTree { this.readonly = !this.type this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href - ctx.on('internal/update', (config, _, next) => { + ctx.on('internal/update', async (config, _, next) => { if (config.path !== this.config.path) return next() - // Veto the fiber restart (children update in place), but persist the new - // config ourselves — `Fiber.update` only assigns `this.config` behind - // `next()`, and a stale `this.config.patches` would make the next - // `refresh()` re-apply the old overlay. + const data = this.applyPatches(this.data!, config.patches) + await this.root.update(data) this.config = config - this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => { - this.ctx.logger.warn('config update at %C failed', this.filename) - this.ctx.logger.warn(error) - }) }) } @@ -192,30 +201,31 @@ export class Include extends EntryTree { } } - private async read(forced = false) { - const content = await readFile(this.filename, 'utf8') - if (!forced && this.content === content) return false + private async read(forced = false): Promise { + let content: string + try { + content = await readFile(this.filename, 'utf8') + } catch (error) { + throw new ConfigFileError('read', this.filename, error) + } + if (!forced && this.content === content) return let data: any - if (this.type === 'application/yaml') { - data = yaml.load(content, { schema }) - } else if (this.type === 'application/json') { - data = JSON.parse(content) - } else { - const module = await import(/* @vite-ignore */ this.filename) - data = module.default || module + try { + if (this.type === 'application/yaml') { + data = yaml.load(content, { schema }) + } else if (this.type === 'application/json') { + data = JSON.parse(content) + } else { + const module = await import(/* @vite-ignore */ this.filename) + data = module.default || module + } + } catch (error) { + throw new ConfigFileError('parse', this.filename, error) } - // An empty or truncated file (common mid-edit: editors and `sed -i` write - // through temp states) parses to `undefined`, not an error; reject every - // non-array shape here so callers see one "invalid file" signal. Content - // and data commit only on success, so an edit that is later reverted to - // the exact last good content correctly reads as "unchanged". if (!Array.isArray(data)) { - throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`) + throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array')) } - this.content = content - this.data = data - await this.checkAccess() - return true + return { content, data } } private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { @@ -225,42 +235,44 @@ export class Include extends EntryTree { } async* [Service.init]() { + let candidate: ReadCandidate try { - await this.read() + candidate = (await this.read(true))! } catch (error) { - // Only a missing file falls back to `initial` (or the not-found error): - // an existing-but-invalid file must fail loud with its real parse error, - // never be mislabelled as absent or silently overwritten. - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error + if (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error if (this.config.initial) { - this.writeFile(this.config.initial as any) - await this.read() + await this._writeFile(this.config.initial as any) + candidate = (await this.read(true))! } else { throw new Error(`config file not found: ${this.filename}`) } } yield () => this.stop() - await this.root.update(this.applyPatches(this.data!)) + await this.apply(candidate) } - stop() { - this.root.stop() + async stop() { + await this.root.stop() } /** - * Re-read the file and refresh child entries when content changed. An - * unreadable or unparsable file logs a warning and keeps the last good - * tree: a hot-reload of a live app must never take the process down. + * Re-read the file and transactionally refresh child entries when content changed. + * @returns a promise resolving after the new tree commits, or immediately when unchanged. + * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds. */ async refresh() { - try { - if (!await this.read()) return - await this.root.update(this.applyPatches(this.data!)) - } catch (error) { - this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename) - this.ctx.logger.warn(error) - } + const candidate = await this.read() + if (!candidate) return + await this.apply(candidate) + } + + private async apply(candidate: ReadCandidate) { + const data = this.applyPatches(candidate.data) + await this.root.update(data) + this.content = candidate.content + this.data = candidate.data + await this.checkAccess() } private async _writeFile(config: EntryOptions[]) { diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index c2959fe61e..d479fa6c0f 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -21,6 +21,11 @@ export interface EntryOptions { inject?: Inject | null } +function updateError(stage: 'import' | 'dispose' | 'apply' | 'rollback', options: EntryOptions, cause: unknown) { + const detail = cause instanceof Error ? cause.message : String(cause) + return new Error(`failed to ${stage} loader entry ${options.id} (${options.name}): ${detail}`, { cause }) +} + function takeEntries(object: {}, keys: string[]) { const result: [string, any][] = [] for (const key of keys) { @@ -38,6 +43,11 @@ function sortKeys(object: T, prepend = ['id', 'name'], append = [' return Object.assign(object, Object.fromEntries([...part1, ...rest, ...part2])) } +function replaceKeys(target: T, source: T): T { + for (const key of Object.keys(target)) Reflect.deleteProperty(target, key) + return Object.assign(target, source) +} + /** One configured plugin node inside an `EntryTree`. */ export class Entry { static readonly key = Symbol.for('cordis.entry') @@ -51,6 +61,7 @@ export class Entry { public subtree?: EntryTree _initTask?: Promise + _disposing = 0 constructor(public loader: Loader) { this.ctx = loader.ctx.extend({ [Entry.key]: this }) @@ -71,13 +82,18 @@ export class Entry { /** True when this entry or any owning parent entry is disabled. */ get disabled() { + return this._disabled(this.options) + } + + private _disabled(options: EntryOptions) { // group is always enabled - if (this.options.group) return false - let entry: Entry | undefined = this - do { + if (options.group) return false + if (options.disabled) return true + let entry = this.parent.ctx.fiber.entry + while (entry) { if (entry.options.disabled) return true entry = entry.parent.ctx.fiber.entry - } while (entry) + } return false } @@ -90,12 +106,12 @@ export class Entry { return interpolate(this.ctx, this.options.config) } - private _patchContext(diff: string[]) { - this.context.waterfall('loader/patch-context', this, () => { + private async _patchContext(diff: string[]) { + await this.context.waterfall('loader/patch-context', this, async () => { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { - this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) + await this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) } }) } @@ -106,41 +122,122 @@ export class Entry { await this.init() } + async _dispose(fiber = this.fiber) { + if (!fiber) return + if (this.fiber === fiber) this.fiber = undefined + this._disposing += 1 + try { + await fiber.dispose() + } finally { + this._disposing -= 1 + } + } + /** Merge new options, restart as needed, and persist through the parent tree. */ async update(options: Partial, create = false, force = false) { - const legacy = { ...this.options } - - // step 1: update options - if (create) { - this.options = options as EntryOptions - } else { + const previousOptions = this.options + const legacy = { ...previousOptions } + const candidate = create ? options as EntryOptions : { ...previousOptions } + if (!create) { for (const [key, value] of Object.entries(options)) { if (isNullable(value)) { - delete this.options[key] + delete candidate[key as keyof EntryOptions] } else { - this.options[key] = value + candidate[key as keyof EntryOptions] = value as never } } } - sortKeys(this.options) + sortKeys(candidate) - // step 2: execute - if (this.disabled) { - this.fiber?.dispose() + const diff = Object + .keys({ ...candidate, ...legacy }) + .filter(key => !deepEqual(candidate[key as keyof EntryOptions], legacy[key as keyof EntryOptions])) + if (!diff.length && !force) return + + const commit = () => { + if (create) return + this.options = replaceKeys(previousOptions, candidate) + } + + const previous = this.fiber + if (!previous?.uid) { + this.fiber = undefined + this.options = candidate + try { + if (!this._disabled(candidate)) await this.init() + } catch (error) { + this.options = previousOptions + throw error + } + commit() return } - // step 3: check if options are changed - if (this.fiber?.uid) { - const diff = Object - .keys({ ...this.options, ...legacy }) - .filter(key => !deepEqual(this.options[key], legacy[key])) - if (!diff.length && !force) return + if (this._disabled(candidate)) { + this.options = candidate + try { + await this._dispose(previous) + } catch (error) { + this.options = previousOptions + throw updateError('dispose', candidate, error) + } + commit() this.context.emit('loader/partial-dispose', this, legacy, true) - this._patchContext(diff) - } else { - await this.init() + return } + + const replace = diff.some(key => key === 'name' || key === 'inject' || key === 'group') + if (!replace) { + this.options = candidate + try { + await this._patchContext(diff) + } catch (error) { + this.options = previousOptions + try { + await this._patchContext(diff) + } catch (rollbackError) { + throw updateError('rollback', legacy, new AggregateError([error, rollbackError])) + } + this.context.emit('loader/partial-dispose', this, candidate, true) + throw updateError('apply', candidate, error) + } + commit() + this.context.emit('loader/partial-dispose', this, legacy, true) + return + } + + let plugin: any + try { + plugin = diff.includes('name') + ? this.loader.unwrapExports(await this.parent.tree.import(candidate.name, this.getOuterStack)) + : previous.runtime!.callback + } catch (error) { + throw updateError('import', candidate, error) + } + + const previousPlugin = previous.runtime!.callback + this.options = candidate + try { + await this._dispose(previous) + } catch (error) { + this.options = previousOptions + throw updateError('dispose', candidate, error) + } + + try { + await this._start(plugin) + } catch (error) { + this.options = previousOptions + try { + await this._start(previousPlugin) + } catch (rollbackError) { + throw updateError('rollback', legacy, new AggregateError([error, rollbackError])) + } + this.context.emit('loader/partial-dispose', this, candidate, true) + throw updateError('apply', candidate, error) + } + commit() + this.context.emit('loader/partial-dispose', this, legacy, true) } getOuterStack = () => { @@ -159,26 +256,39 @@ export class Entry { await (this._initTask ??= this._init()) } finally { this._initTask = undefined + if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader']) } - this.fiber?.await().finally(() => { - if (this.loader.getTasks().length) return - this.ctx.reflect.notify(['loader']) - }) + await this.fiber?.await() } private async _init() { - let exports: any + let plugin: any try { - exports = await this.parent.tree.import(this.options.name, this.getOuterStack) + plugin = this.loader.unwrapExports(await this.parent.tree.import(this.options.name, this.getOuterStack)) } catch (error) { - this.ctx.logger.error(error) - return - } finally { - this._initTask = undefined + throw updateError('import', this.options, error) } - const plugin = this.loader.unwrapExports(exports) - this._patchContext([]) + try { + await this._start(plugin) + } catch (error) { + throw updateError('apply', this.options, error) + } + } + + private async _start(plugin: any) { + let fiber: Fiber | undefined + try { + fiber = await this._create(plugin) + await fiber.await() + } catch (error) { + await this._dispose(fiber) + throw error + } + } + + private async _create(plugin: any): Promise { + await this._patchContext([]) this.loader.showLog(this, 'apply') - this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack) + return this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack) } } diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index a73e4dea0f..cdd613caf6 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -19,12 +19,23 @@ export class EntryGroup { async create(options: Omit) { const id = this.tree.ensureId(options) - const entry: Entry = this.tree.store[id] ??= new Entry(this.ctx.loader) + const existing = this.tree.store[id] + const entry: Entry = existing ?? (this.tree.store[id] = new Entry(this.ctx.loader)) + const previousParent = entry.parent // Entry may be moved from another group, // so we need to update the parent reference. entry.parent = this // Use `create: true` to replace existing entry.options. - await entry.update(options, true, true) + try { + await entry.update(options, true, true) + } catch (error) { + if (existing) { + entry.parent = previousParent + } else { + delete this.tree.store[id] + } + throw error + } return entry.id } @@ -34,10 +45,10 @@ export class EntryGroup { if (index >= 0) config.splice(index, 1) } - remove(id: string, isDispose = false) { + async remove(id: string, isDispose = false) { const entry = this.tree.store[id] if (!entry) return - entry.fiber?.dispose() + await entry._dispose() if (!isDispose) { this.unlink(entry.options) } @@ -47,26 +58,47 @@ export class EntryGroup { async update(config: EntryOptions[]) { const oldConfig = this.data as EntryOptions[] - this.data = config + const seen = new Set() + for (const options of config) { + const id = this.tree.ensureId(options) + if (seen.has(id)) throw new TypeError(`duplicate loader entry id: ${id}`) + seen.add(id) + } const oldMap = Object.fromEntries(oldConfig.map(options => [options.id, options])) - const newMap = Object.fromEntries(config.map(options => [options.id ?? Symbol('anonymous'), options])) + const newMap = Object.fromEntries(config.map(options => [options.id, options])) - // update inner plugins - const ids = Reflect.ownKeys({ ...oldMap, ...newMap }) as string[] - await Promise.all(ids.map(async (id) => { - if (newMap[id]) { - await this.create(newMap[id]).catch((error) => { - this.ctx.logger.error(error) - }) - } else { - this.remove(id) + try { + for (const options of config) await this.create(options) + for (const id of Object.keys(oldMap)) { + if (!newMap[id]) await this.remove(id, true) } - })) + this.data = config + } catch (error) { + const rollbackErrors: unknown[] = [] + for (const id of Object.keys(newMap).reverse()) { + if (oldMap[id]) continue + try { + await this.remove(id, true) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + for (const options of oldConfig) { + try { + await this.create(options) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + this.data = oldConfig + if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], 'loader entry rollback failed') + throw error + } } - stop() { + async stop() { for (const options of this.data) { - this.remove(options.id, true) + await this.remove(options.id, true) } } } @@ -78,9 +110,7 @@ export class Group extends EntryGroup { constructor(public ctx: Context, public config: EntryOptions[]) { super(ctx, ctx.fiber.entry!.parent.tree) - ctx.on('internal/update', (config) => { - this.update(config) - }) + ctx.on('internal/update', config => this.update(config)) } async* [Service.init]() { diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index 2361b41aaa..9142f3fda5 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -93,7 +93,7 @@ export default function isolate(ctx: Context) { entry.ctx[Context.isolate] = Object.create(entry.ctx[Context.isolate]) }) - ctx.on('loader/patch-context', (entry, next) => { + ctx.on('loader/patch-context', async (entry, next) => { // step 1: generate new isolate map const newMap: Dict = Object.create(entry.parent.ctx[Context.isolate]) for (const name of Object.keys(entry.options.isolate ?? {})) { @@ -126,7 +126,7 @@ export default function isolate(ctx: Context) { swap(entry.ctx[Context.intercept], entry.options.intercept) // step 4: reload fiber - next() + await next() // step 5: replace service impl for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) { diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 79db440601..8cb9fb984d 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -39,12 +39,27 @@ export abstract class EntryTree { .filter(isNonNullable) } - /** Wait until this tree has no pending import or lifecycle tasks. */ + /** + * Wait until this tree has no active import or lifecycle tasks. + * @throws a settled fiber failure, or an aggregate when several fibers failed. + */ async await() { while (true) { const tasks = this.getTasks() - if (!tasks.length) return - await Promise.allSettled(tasks) + if (tasks.length) { + await Promise.allSettled(tasks) + continue + } + const outcomes = await Promise.allSettled( + [...this.entries()].map(entry => entry.fiber?.await()), + ) + const failures = outcomes + .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') + .map(outcome => outcome.reason) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'loader fibers failed') + this.ctx.reflect.notify(['loader']) + if (!this.getTasks().length) return } } @@ -81,15 +96,17 @@ export abstract class EntryTree { /** Create an entry in the root group or a nested group. */ async create(options: Omit, parent: string | null = null, position = Infinity) { const group = this.resolveGroup(parent) - group.data.splice(position, 0, options as EntryOptions) + const id = await group.create(options) + const entry = this.resolve(id) + group.data.splice(position, 0, entry.options) group.tree.write() - return group.create(options) + return id } /** Stop and remove an entry from its parent group. */ - remove(id: string) { + async remove(id: string) { const entry = this.resolve(id) - entry.parent.remove(id) + await entry.parent.remove(id) entry.parent.tree.write() } @@ -97,15 +114,31 @@ export abstract class EntryTree { async update(id: string, options: Omit, parent?: string | null, position?: number) { const entry = this.resolve(id) const source = entry.parent + const sourceIndex = source.data.indexOf(entry.options) + let target = source if (parent !== undefined) { - const target = this.resolveGroup(parent) + target = this.resolveGroup(parent) source.unlink(entry.options) target.data.splice(position ?? Infinity, 0, entry.options) - target.tree.write() entry.parent = target } + try { + await entry.update(options, false, true) + } catch (error) { + if (parent !== undefined) { + target.unlink(entry.options) + source.data.splice(sourceIndex < 0 ? source.data.length : sourceIndex, 0, entry.options) + entry.parent = source + try { + await entry.update({}, false, true) + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], `failed to roll back loader entry move ${id}`) + } + } + throw error + } source.tree.write() - return entry.update(options, false, true) + if (target !== source) target.tree.write() } /** Import a plugin module from a specifier or `cordis:` builtin. */ diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index 1e963ea073..798354c7b0 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -24,7 +24,7 @@ declare module 'cordis' { 'loader/config-update'(): void 'loader/entry-init'(entry: Entry): void 'loader/partial-dispose'(entry: Entry, legacy: Partial, active: boolean): void - 'loader/patch-context'(entry: Entry, next: () => void): void + 'loader/patch-context'(entry: Entry, next: () => void | Promise): void | Promise } interface Context { @@ -87,12 +87,12 @@ export class Loader extends EntryTree { ctx.reflect.provide('loader', this, this[Service.check]) - ctx.on('internal/update', function (config, noSave, next) { + ctx.on('internal/update', async function (config, noSave, next) { if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next() + await next() const unparse = this.runtime?.Config?.['simplify'] this.entry.options.config = unparse ? unparse(config) : config this.entry.parent.tree.write() - return next() }, { global: true, prepend: true }) ctx.on('internal/update', function (config, _, next) { @@ -129,9 +129,12 @@ export class Loader extends EntryTree { // case 5: the entry's tree is being disposed if (!fiber.entry.parent.tree.ctx.fiber.uid) return + // case 6: Loader is replacing or removing this exact fiber + if (fiber.entry._disposing) return + this.showLog(fiber.entry, 'unload') - // case 6: fiber is disposed by loader behavior + // case 7: fiber is disposed by loader behavior // such as inject checker, config file update, ancestor group disable if (fiber.entry.disabled) return From 560f3abd21da8fa8e3a2517cc04c2ed9fa571eff Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:46:04 +0800 Subject: [PATCH 010/114] fix(vendor): link Cordis workspaces in built artifacts --- ...26-06-11-vendor-cordis-as-source.i18n.yaml | 6 +- .../2026-06-11-vendor-cordis-as-source.md | 3 +- .../2026-06-11-vendor-cordis-as-source.zh.md | 3 +- pnpm-lock.yaml | 678 ++++++++---------- pnpm-workspace.yaml | 4 + vendor/README.md | 2 +- vendor/schemastery/package.json | 9 + 7 files changed, 312 insertions(+), 393 deletions(-) diff --git a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml index 0e0c6693a2..93cf9ec401 100644 --- a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-11-vendor-cordis-as-source.md: ae6f5438c5817c61a549d9edb2041d538fbcebe6 -2026-06-11-vendor-cordis-as-source.zh.md: 8d6f0e39d53e1c85eaaa50c4c4bf1d9ef648d953 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md +2026-06-11-vendor-cordis-as-source.md: ccc1289c8a0feadc08d80a3b6e8dc674c1b87bc4 +2026-06-11-vendor-cordis-as-source.zh.md: 9abea504d677ab71c62d24e2e4d9dfde4315802c diff --git a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md index ae6f5438c5..ccc1289c8a 100644 --- a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md +++ b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.md @@ -10,7 +10,7 @@ DeepSeek Harness SDK is built on the Cordis framework. Cordis core was at 4.0.0- ## Decision -Copy the needed Cordis packages (core, loader, include, group, timer, hmr, logger-console) and the cordiverse foundation libraries (cosmokit, schemastery) into `vendor/` as source, flattened, keeping their original npm names so workspace resolution is transparent. Truly third-party dependencies (js-yaml, chokidar, @standard-schema/spec, …) stay on npm. +Copy the needed Cordis packages (core, loader, include, group, timer, hmr, logger-console) and the cordiverse foundation libraries (cosmokit, schemastery) into `vendor/` as source, flattened, keeping their original npm names so workspace resolution is transparent. `pnpm-workspace.yaml` sets `linkWorkspacePackages: true`, so matching upstream semver ranges resolve these pinned workspaces in both source and built-artifact execution. Truly third-party dependencies (js-yaml, chokidar, @standard-schema/spec, …) stay on npm. `vendor/README.md` is the manifest: upstream repo + commit SHA per package and an exhaustive local-modification log. A pre-commit guard (`scripts/check-vendor-manifest.sh`) rejects vendored-source changes that don't update the manifest in the same commit. @@ -22,6 +22,7 @@ Copy the needed Cordis packages (core, loader, include, group, timer, hmr, logge ## Consequences - The harness fully owns its framework layer: auditable, patchable, pinned — an RC upstream can't break us, and we can fix framework bugs in-tree. +- Built packages execute the same vendored Cordis generation as source tests; removing workspace linking would silently substitute npm copies behind unchanged package names. - Upstream sync is manual (documented procedure in the manifest). The modification log keeps the diff surface known. - Vendored packages keep upstream code style; lint/strictness gates exclude them (their tsconfigs relax our newer compiler flags locally). - One local patch exists from day one: hmr's locale-YAML imports removed (the runtime YAML import hook isn't vendored). diff --git a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md index 8d6f0e39d5..9abea504d6 100644 --- a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness SDK 构建于 Cordis 框架之上。本仓库启动时,Cordis ## 决策 -将所需的 Cordis 包(core、loader、include、group、timer、hmr、logger-console)与 cordiverse 基础库(cosmokit、schemastery)以源码形式复制到 `vendor/`,扁平化放置,保留其原始 npm 包名以实现透明的 workspace 解析。真正的第三方依赖(js-yaml、chokidar、@standard-schema/spec 等)仍从 npm 获取。 +将所需的 Cordis 包(core、loader、include、group、timer、hmr、logger-console)与 cordiverse 基础库(cosmokit、schemastery)以源码形式复制到 `vendor/`,扁平化放置,保留其原始 npm 包名以实现透明的 workspace 解析。`pnpm-workspace.yaml` 设置 `linkWorkspacePackages: true`,所以只要上游 semver 范围匹配,无论以源码执行还是以构建产物执行,依赖都会解析到这些固定版本的 workspace。真正的第三方依赖(js-yaml、chokidar、@standard-schema/spec 等)仍从 npm 获取。 `vendor/README.md` 是 manifest(元数据清单):记录每个包(package)的上游仓库 + commit SHA,以及一份详尽的本地修改日志。pre-commit 守卫(`scripts/check-vendor-manifest.sh`)会拒绝未在同一次提交中更新 manifest 的 vendor 源码变更。 @@ -22,6 +22,7 @@ DeepSeek Harness SDK 构建于 Cordis 框架之上。本仓库启动时,Cordis ## 后果 - harness 完全持有其框架层:可审计、可打补丁、版本锁定。上游 RC 无法影响我们,框架 bug 可以在仓库内直接修复。 +- 构建后的包与源码测试执行的是同一版收录的 Cordis;移除 workspace 链接后,构建后的包会在包名不变的情况下静默改用 npm 副本。 - 上游同步是手动操作(流程记录在 manifest 中)。修改日志使 diff 范围始终可知。 - 收录的包保留上游代码风格;lint 与严格性门禁将其排除(它们的 tsconfig 在本地放宽了我们较新的编译器选项)。 - 从第一天起就有一个本地补丁:移除了 hmr 的 locale-YAML 导入(运行时 YAML 导入钩子未被收录)。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dff92c6511..cefd39d0ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -488,7 +488,7 @@ importers: version: 15.0.0 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../vendor/cordis js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -841,7 +841,7 @@ importers: version: 0.25.1(zod@4.4.3) schemastery: specifier: ^3.17.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -869,7 +869,7 @@ importers: version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/bash/bash: devDependencies: @@ -884,13 +884,13 @@ importers: version: link:../../subprocess/subprocess cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/bash/bash-local: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -909,7 +909,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/bash/bash-sandbox: devDependencies: @@ -936,7 +936,7 @@ importers: version: link:../../subprocess/subprocess-local cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis node-addon-landlock-run: specifier: 0.0.0-test.0 version: 0.0.0-test.0 @@ -945,7 +945,7 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -1009,7 +1009,7 @@ importers: version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/connection: dependencies: @@ -1030,7 +1030,7 @@ importers: version: link:../../core/tools schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ @@ -1040,13 +1040,13 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/hmr: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -1062,7 +1062,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/client/locale: devDependencies: @@ -1083,7 +1083,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1101,7 +1101,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/client/runtime: dependencies: @@ -1156,20 +1156,20 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/schema-form: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/test-runtime: dependencies: @@ -1206,7 +1206,7 @@ importers: version: 18.3.7(@types/react@18.3.31) cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1252,7 +1252,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1310,7 +1310,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1352,7 +1352,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1382,7 +1382,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1424,7 +1424,7 @@ importers: version: 2.1.1 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1466,7 +1466,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1511,7 +1511,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1553,7 +1553,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1614,7 +1614,7 @@ importers: version: 18.3.7(@types/react@18.3.31) cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/ui-question: dependencies: @@ -1666,7 +1666,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/ui-settings: dependencies: @@ -1700,7 +1700,7 @@ importers: version: 18.3.7(@types/react@18.3.31) cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1715,7 +1715,7 @@ importers: version: link:../../settings/settings schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -1749,7 +1749,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1786,7 +1786,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1810,7 +1810,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/ui-slash: dependencies: @@ -1841,7 +1841,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1856,7 +1856,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/ui-subagent: devDependencies: @@ -1874,7 +1874,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/client/ui-theme: dependencies: @@ -1905,7 +1905,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1936,7 +1936,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1976,7 +1976,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2028,7 +2028,7 @@ importers: version: 18.3.7(@types/react@18.3.31) cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis typescript: specifier: ^6.0.3 version: 6.0.3 @@ -2053,7 +2053,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/code-runtime/code-runtime: devDependencies: @@ -2062,13 +2062,13 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/code-runtime/code-runtime-worker: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ @@ -2084,7 +2084,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/compact/command-compact: devDependencies: @@ -2114,7 +2114,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/compact/compact: devDependencies: @@ -2129,13 +2129,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/compact/compact-basic: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -2178,13 +2178,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/compact/compact-tool-result-prune: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -2203,13 +2203,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/context/session-reference: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2234,13 +2234,13 @@ importers: version: link:../../session-query/session-query cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/context/time-context: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2271,13 +2271,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/context/tmux-context: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2299,13 +2299,13 @@ importers: version: link:../../core/system-prompt cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/context/workspace-context: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -2348,17 +2348,17 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/cordis/tool-cordis: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@cordisjs/plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer @@ -2391,7 +2391,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/core/agent: devDependencies: @@ -2415,13 +2415,13 @@ importers: version: link:../system-prompt cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/core/agent-loop: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2452,7 +2452,7 @@ importers: version: link:../tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/core/scope: devDependencies: @@ -2461,7 +2461,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/core/session: devDependencies: @@ -2479,13 +2479,13 @@ importers: version: link:../scope cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/core/system-prompt: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -2498,13 +2498,13 @@ importers: version: link:../scope cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/core/tools: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2532,7 +2532,7 @@ importers: version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/credentials/credentials: devDependencies: @@ -2544,7 +2544,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/credentials/credentials-local: dependencies: @@ -2556,7 +2556,7 @@ importers: version: 17.4.2 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ @@ -2572,7 +2572,7 @@ importers: version: link:../../util/paths cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/examples/acp-demo: devDependencies: @@ -2620,16 +2620,16 @@ importers: version: link:../../context/workspace-context cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis schemastery: specifier: ^3.17.0 - version: 3.18.0 + version: link:../../../vendor/schemastery packages/examples/agent-spine-demo: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-timer': specifier: workspace:^ @@ -2729,7 +2729,7 @@ importers: version: link:../../context/workspace-context cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis node-addon-landlock-run: specifier: 0.0.0-test.0 version: 0.0.0-test.0 @@ -2777,10 +2777,10 @@ importers: version: link:../../context/workspace-context cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis schemastery: specifier: ^3.17.0 - version: 3.18.0 + version: link:../../../vendor/schemastery packages/examples/jsonrpc-demo: dependencies: @@ -2793,7 +2793,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/fs: devDependencies: @@ -2811,7 +2811,7 @@ importers: version: link:../../sandbox/sandbox cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/fs-local: dependencies: @@ -2820,7 +2820,7 @@ importers: version: 3.1.1 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-fs': specifier: workspace:^ @@ -2833,7 +2833,7 @@ importers: version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/fs-policy: devDependencies: @@ -2848,7 +2848,7 @@ importers: version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/fs-sandbox: devDependencies: @@ -2869,7 +2869,7 @@ importers: version: link:../../sandbox/sandbox-policy cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/tool-fs: dependencies: @@ -2878,7 +2878,7 @@ importers: version: 9.0.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2927,13 +2927,13 @@ importers: version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/tool-fs-search: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2970,13 +2970,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/fs/tool-str-replace-editor: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3016,7 +3016,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/goal/command-goal: devDependencies: @@ -3043,13 +3043,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/goal/goal: dependencies: schemastery: specifier: ^3.17.2 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -3080,7 +3080,7 @@ importers: version: link:../../session-projection/session-projection cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/goal/goal-session: devDependencies: @@ -3113,13 +3113,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/goal/tool-goal: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -3147,13 +3147,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/guard/repeat-tool-guard: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3178,7 +3178,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/hooks/hook-protocol: devDependencies: @@ -3193,13 +3193,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/hooks/hooks-claude: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3245,13 +3245,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/hooks/hooks-codex: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3294,7 +3294,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/host/apiproxy: dependencies: @@ -3360,7 +3360,7 @@ importers: version: link:../../workspace/workspace schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -3376,7 +3376,7 @@ importers: version: link:../../storage/storage-domain cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/host/directory-picker: devDependencies: @@ -3385,7 +3385,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/host/directory-picker-auto: devDependencies: @@ -3412,7 +3412,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/host/directory-picker-browse: dependencies: @@ -3424,7 +3424,7 @@ importers: version: 2.1.1 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ @@ -3452,7 +3452,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -3483,7 +3483,7 @@ importers: version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -3492,20 +3492,20 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/llm/llm: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -3518,7 +3518,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/llm/llm-deepseek: dependencies: @@ -3527,7 +3527,7 @@ importers: version: 3.1.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-credentials': specifier: workspace:^ @@ -3546,7 +3546,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/llm/llm-pi-ai: dependencies: @@ -3555,7 +3555,7 @@ importers: version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-credentials': specifier: workspace:^ @@ -3577,13 +3577,13 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/llm/llm-retry: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -3632,13 +3632,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/llm/token-meter: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -3657,7 +3657,7 @@ importers: version: link:../../session-projection/session-projection cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/lsp/lsp: devDependencies: @@ -3672,13 +3672,13 @@ importers: version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/lsp/lsp-local: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -3703,7 +3703,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis typescript: specifier: ^6.0.3 version: 6.0.3 @@ -3715,7 +3715,7 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3749,7 +3749,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/mcp/mcp-client: dependencies: @@ -3758,7 +3758,7 @@ importers: version: 1.29.0(zod@4.4.3) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -3783,7 +3783,7 @@ importers: version: 2026.7.10(zod@4.4.3) cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/plan/plan-mode: dependencies: @@ -3826,7 +3826,7 @@ importers: version: link:../../ui/user-interaction cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/pty/pty: devDependencies: @@ -3844,7 +3844,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/pty/pty-local: dependencies: @@ -3853,7 +3853,7 @@ importers: version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3878,13 +3878,13 @@ importers: version: link:../../subprocess/subprocess cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/pty/tool-bash-persistent: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -3927,13 +3927,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/pty/tool-pty: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -3985,7 +3985,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/sandbox/sandbox: devDependencies: @@ -3997,7 +3997,7 @@ importers: version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sandbox/sandbox-local: dependencies: @@ -4006,7 +4006,7 @@ importers: version: 0.0.0-test.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4019,13 +4019,13 @@ importers: version: link:../sandbox cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sandbox/sandbox-policy: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4044,7 +4044,7 @@ importers: version: link:../../core/system-prompt cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sdk/create-sdk: dependencies: @@ -4060,7 +4060,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sdk/helper: dependencies: @@ -4109,7 +4109,7 @@ importers: version: link:../../web/tool-web cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sdk/scripts: dependencies: @@ -4134,7 +4134,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis tsdown: specifier: ^0.22.2 version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) @@ -4158,7 +4158,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sdk/sdk-protocol: devDependencies: @@ -4176,7 +4176,7 @@ importers: version: link:../../subagent/subagent cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/sdk/telemetry: dependencies: @@ -4195,7 +4195,7 @@ importers: version: link:../../util/paths cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-persistence/session-checkpoint-policy: devDependencies: @@ -4234,7 +4234,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/session-persistence/session-persistence: devDependencies: @@ -4252,7 +4252,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-persistence/session-persistence-jsonl: dependencies: @@ -4261,7 +4261,7 @@ importers: version: 3.1.1 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4274,13 +4274,13 @@ importers: version: link:../session-persistence cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-persistence/session-persistence-sqlite: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4293,7 +4293,7 @@ importers: version: link:../session-persistence cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-projection/session-projection: dependencies: @@ -4309,13 +4309,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-projection/session-projection-cache: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -4340,7 +4340,7 @@ importers: version: link:../../storage/storage-domain cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-query/session-query: devDependencies: @@ -4364,13 +4364,13 @@ importers: version: link:../../session-title/session-title cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-query/session-query-sqlite: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -4392,13 +4392,13 @@ importers: version: link:../session-query cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/session-query/tool-session-query: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4441,13 +4441,13 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-title/session-title: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -4475,13 +4475,13 @@ importers: version: link:../../session-projection/session-projection cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-title/session-title-all-messages-llm: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4500,13 +4500,13 @@ importers: version: link:../session-title-llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/session-title/session-title-first-message-llm: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -4534,13 +4534,13 @@ importers: version: link:../session-title-llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/session-title/session-title-llm: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4559,7 +4559,7 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/settings/settings: devDependencies: @@ -4571,10 +4571,10 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery packages/settings/settings-local: dependencies: @@ -4583,7 +4583,7 @@ importers: version: 4.0.3 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery yaml: specifier: ^2.9.0 version: 2.9.0 @@ -4602,20 +4602,20 @@ importers: version: link:../settings cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/skill/skill: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/skill/skill-local: dependencies: @@ -4624,7 +4624,7 @@ importers: version: 5.0.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery yaml: specifier: ^2.4.2 version: 2.9.0 @@ -4643,13 +4643,13 @@ importers: version: link:../skill cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/skill/tool-skill: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4677,7 +4677,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/spill/spill: devDependencies: @@ -4695,13 +4695,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/spill/spill-local: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -4720,13 +4720,13 @@ importers: version: link:../spill cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/spill/spill-policy: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4754,7 +4754,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/storage/storage: devDependencies: @@ -4763,13 +4763,13 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/storage/storage-domain: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -4782,13 +4782,13 @@ importers: version: link:../storage cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/storage/storage-json: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4798,13 +4798,13 @@ importers: version: link:../storage cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/storage/storage-sqlite: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4814,7 +4814,7 @@ importers: version: link:../storage cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/subagent: devDependencies: @@ -4841,7 +4841,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/subagent-acp: dependencies: @@ -4850,11 +4850,11 @@ importers: version: 0.25.1(zod@4.4.3) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4881,17 +4881,17 @@ importers: version: link:../../subprocess/subprocess-local cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/subagent-dsh-sdk: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4921,17 +4921,17 @@ importers: version: link:../../subprocess/subprocess cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/subagent-fork: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4961,7 +4961,7 @@ importers: version: link:../subagent-spawn cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/subagent-inprocess: devDependencies: @@ -5006,17 +5006,17 @@ importers: version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/subagent-spawn: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5058,17 +5058,17 @@ importers: version: link:../tool-subagent cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subagent/tool-subagent: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5098,7 +5098,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subprocess/subprocess: devDependencies: @@ -5107,7 +5107,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/subprocess/subprocess-local: devDependencies: @@ -5119,7 +5119,7 @@ importers: version: link:../subprocess cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/support/acp-snapshot: dependencies: @@ -5138,7 +5138,7 @@ importers: version: link:../invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/support/agent-loop-testkit: devDependencies: @@ -5165,17 +5165,17 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/support/invariants: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/support/llm-mock-server: devDependencies: @@ -5184,7 +5184,7 @@ importers: version: link:../invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/support/llm-replay: devDependencies: @@ -5199,7 +5199,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/support/loader-smoke: dependencies: @@ -5215,7 +5215,7 @@ importers: version: link:../invariants cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/tasks/tasks: devDependencies: @@ -5233,7 +5233,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/tasks/tasks-local: devDependencies: @@ -5257,13 +5257,13 @@ importers: version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/tasks/tool-tasks: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5294,7 +5294,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/telemetry/session-telemetry: devDependencies: @@ -5309,7 +5309,7 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/telemetry/session-telemetry-otel: dependencies: @@ -5333,7 +5333,7 @@ importers: version: 0.220.0(@opentelemetry/api@1.9.1) schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -5358,7 +5358,7 @@ importers: version: link:../session-telemetry cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/timeout/timeout-policy: devDependencies: @@ -5376,7 +5376,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/todo/tool-todo: dependencies: @@ -5419,7 +5419,7 @@ importers: version: link:../../ui/user-interaction cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/typert/generator: dependencies: @@ -5438,7 +5438,7 @@ importers: version: link:../registry cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis zod: specifier: ^4.4.3 version: 4.4.3 @@ -5447,7 +5447,7 @@ importers: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -5460,7 +5460,7 @@ importers: version: link:../registry cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis zod: specifier: ^4.4.3 version: 4.4.3 @@ -5476,7 +5476,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/ui/app-boot: dependencies: @@ -5510,7 +5510,7 @@ importers: version: 4.0.9 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/ui/commands: devDependencies: @@ -5531,13 +5531,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/ui/jsonrpc: dependencies: schemastery: specifier: ^3.17.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -5574,13 +5574,13 @@ importers: version: link:../../subagent/subagent cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/ui/permission: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -5614,7 +5614,7 @@ importers: version: link:../user-approval cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/ui/tool-ask-user: devDependencies: @@ -5638,7 +5638,7 @@ importers: version: link:../user-interaction cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/ui/tui: dependencies: @@ -5650,7 +5650,7 @@ importers: version: 6.0.0 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -5726,13 +5726,13 @@ importers: version: 5.5.0 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/ui/user-approval: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5757,7 +5757,7 @@ importers: version: link:../../core/system-prompt cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/ui/user-interaction: devDependencies: @@ -5772,7 +5772,7 @@ importers: version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/util/atomic-write: devDependencies: @@ -5781,7 +5781,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/util/brand: devDependencies: @@ -5790,7 +5790,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/util/native-command: devDependencies: @@ -5799,7 +5799,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/util/paths: devDependencies: @@ -5808,7 +5808,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/util/retention: devDependencies: @@ -5817,7 +5817,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/util/timeout: devDependencies: @@ -5826,7 +5826,7 @@ importers: version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/web/tool-web: dependencies: @@ -5835,7 +5835,7 @@ importers: version: 1.0.67 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery turndown: specifier: ^7.2.4 version: 7.2.4 @@ -5881,13 +5881,13 @@ importers: version: 5.0.6 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/web/web: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5897,13 +5897,13 @@ importers: version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/web/web-fetch-local: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5916,13 +5916,13 @@ importers: version: link:../web cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/web/web-search-deepseek: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5944,13 +5944,13 @@ importers: version: link:../web cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/web/web-search-exa: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5960,13 +5960,13 @@ importers: version: link:../web cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/web/web-search-perplexity: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5976,13 +5976,13 @@ importers: version: link:../web cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/workflow/tool-ralph: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@cordisjs/plugin-loader': specifier: workspace:^ @@ -6028,13 +6028,13 @@ importers: version: link:../workflow-workerthread cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/workflow/tool-workflow: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6065,7 +6065,7 @@ importers: version: link:../workflow-workerthread cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/workflow/workflow: devDependencies: @@ -6086,13 +6086,13 @@ importers: version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis packages/workflow/workflow-workerthread: dependencies: schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6132,7 +6132,7 @@ importers: version: link:../workflow cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis tsx: specifier: ^4.19.2 version: 4.22.4 @@ -6163,7 +6163,7 @@ importers: version: link:../../storage/storage-domain cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../../../vendor/cordis python/sdk-runtime: dependencies: @@ -6466,16 +6466,16 @@ importers: dependencies: '@cordisjs/plugin-include': specifier: ^1.0.4 - version: 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7) + version: link:../include '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../loader '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit vendor/cosmokit: {} @@ -6483,10 +6483,10 @@ importers: dependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../loader cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../cordis vendor/hmr: dependencies: @@ -6495,22 +6495,22 @@ importers: version: 7.29.7 '@cordisjs/plugin-timer': specifier: ^1.1.2 - version: 1.1.2(cordis@4.0.0-rc.7) + version: link:../timer chokidar: specifier: ^4.0.3 version: 4.0.3 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit picomatch: specifier: ^4.0.3 version: 4.0.4 schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../schemastery devDependencies: '@types/babel__code-frame': specifier: ^7.27.0 @@ -6526,13 +6526,13 @@ importers: dependencies: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) + version: link:../loader cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit js-yaml: specifier: ^4.1.0 version: 4.2.0 @@ -6541,10 +6541,10 @@ importers: dependencies: cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit node-addon-require-builtin: specifier: ^0.1.3 version: 0.1.3 @@ -6553,13 +6553,13 @@ importers: dependencies: cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit schemastery: specifier: ^3.18.0 - version: 3.18.0 + version: link:../schemastery supports-color: specifier: ^9.4.0 version: 9.4.0 @@ -6571,16 +6571,16 @@ importers: version: 1.1.0 cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit vendor/timer: dependencies: cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: link:../cordis cosmokit: specifier: ^1.8.1 - version: 1.8.1 + version: link:../cosmokit website: devDependencies: @@ -6956,26 +6956,6 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} - '@cordisjs/plugin-include@1.0.4': - resolution: {integrity: sha512-b1Hm1wmue0v7d/jayoXoBjCV2J14XWTL5yyDZEYeL2L9HgcyTq6JbCw99ozSbei98uAbkwq/pBhimsp/HsySeg==} - peerDependencies: - '@cordisjs/plugin-loader': ^1.0.0-rc.4 - cordis: ^4.0.0-rc.5 - - '@cordisjs/plugin-loader@1.0.0-rc.5': - resolution: {integrity: sha512-084Wn2SzkFinbaASTq8blHOUqQt/oxZfX6gnrt0lnJ1CrystulFLL1+XVgF4o7lUMN9bHn4cfT1pMtkHprCtHw==} - peerDependencies: - cordis: ^4.0.0-rc.7 - node-addon-require-builtin: ^0.1.0 - peerDependenciesMeta: - node-addon-require-builtin: - optional: true - - '@cordisjs/plugin-timer@1.1.2': - resolution: {integrity: sha512-5z5C3Eewt8JzK9XGy5JgIoYFRqXPWZnT7hHFfuJMQNzSom6iEVeLXpYiMvqVqGfJicHA7IroaOjcLRf99sidrQ==} - peerDependencies: - cordis: ^4.0.0-rc.5 - '@csstools/color-helpers@6.1.0': resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} engines: {node: '>=20.19.0'} @@ -9350,18 +9330,6 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} - cordis@4.0.0-rc.7: - resolution: {integrity: sha512-5nm6ehrSfJhEUV659CctEvyNuBY/AXapw8+ZEw7YENztdzpiT+Ha8nIfkyhfyAgPtJns9aB5On5nzl9Sm6zHeQ==} - hasBin: true - peerDependencies: - '@cordisjs/plugin-include': ^1.0.4 - '@cordisjs/plugin-loader': ^1.0.0-rc.5 - peerDependenciesMeta: - '@cordisjs/plugin-include': - optional: true - '@cordisjs/plugin-loader': - optional: true - core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -9375,9 +9343,6 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} - cosmokit@1.8.1: - resolution: {integrity: sha512-PDBv4l90xZKrUsZ0vtoycgZpO/j4iFsqJXrAxsyBDsnQRI7ZMJXIjgDJsKNjd5L8jnVnnlrDCdhkFbTncgCVjQ==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -11206,9 +11171,6 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - schemastery@3.18.0: - resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==} - scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} @@ -12463,33 +12425,6 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7)': - dependencies: - '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) - cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - cosmokit: 1.8.1 - js-yaml: 4.2.0 - - '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.7)': - dependencies: - '@cordisjs/plugin-loader': link:vendor/loader - cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - cosmokit: 1.8.1 - js-yaml: 4.2.0 - optional: true - - '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3)': - dependencies: - cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - cosmokit: 1.8.1 - optionalDependencies: - node-addon-require-builtin: 0.1.3 - - '@cordisjs/plugin-timer@1.1.2(cordis@4.0.0-rc.7)': - dependencies: - cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - cosmokit: 1.8.1 - '@csstools/color-helpers@6.1.0': {} '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -14429,30 +14364,6 @@ snapshots: dependencies: is-what: 5.5.0 - cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5): - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - optionalDependencies: - '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7) - '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.3) - - cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader): - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - optionalDependencies: - '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.7) - '@cordisjs/plugin-loader': link:vendor/loader - - cordis@4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader): - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - optionalDependencies: - '@cordisjs/plugin-include': link:vendor/include - '@cordisjs/plugin-loader': link:vendor/loader - core-util-is@1.0.3: {} cors@2.8.6: @@ -14468,8 +14379,6 @@ snapshots: dependencies: layout-base: 2.0.1 - cosmokit@1.8.1: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -16809,11 +16718,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - schemastery@3.18.0: - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.12.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eb7a7c6322..751037b822 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,10 @@ packages: # closure is what the exe bundles and what the Python runtime distributes. - python/sdk-runtime +# Vendored framework packages keep their upstream semver ranges, while local +# builds must resolve those matching names to this workspace's pinned sources. +linkWorkspacePackages: true + peerDependencyRules: allowedVersions: typescript: '>=5 <7' diff --git a/vendor/README.md b/vendor/README.md index 1ad2b94e41..4fc9f66cc9 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -2,7 +2,7 @@ This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned). -All vendored packages keep their **original npm names** (they are resolved through pnpm workspaces) and are marked `private: true` — they are never published from this repo. Upstream MIT `LICENSE` files are preserved in each package directory. +All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md). diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index 8433f35ec8..f23fac56db 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -7,6 +7,15 @@ "main": "lib/index.cjs", "module": "lib/index.mjs", "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "import": "./lib/index.mjs", + "require": "./lib/index.cjs" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, "files": [ "lib/index.mjs", "lib/index.cjs", From 2fe205f01aed6926796abf62ed043b2d709f4d80 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:18:31 +0800 Subject: [PATCH 011/114] fix(cordis): clear omitted include patches --- packages/ui/app-boot/tests/config-reload.spec.ts | 6 +++--- vendor/README.md | 2 +- vendor/include/src/index.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index 1a236e906b..d9f4ffa830 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -328,9 +328,9 @@ describe('include refresh with overlay patches', () => { await ctx.loader.await() expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' }) - // Removing every patch must revert to the file's own values: patching - // may not bake earlier patch results into the cached parse. - await entry.update({ config: { path: './base.yml', patches: [] } }) + // Omitting the patch list must remove the overlay rather than reuse the + // Include's previous config through a default parameter. + await entry.update({ config: { path: './base.yml' } }) await ctx.loader.await() expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' }) } finally { diff --git a/vendor/README.md b/vendor/README.md index 4fc9f66cc9..8a1bb3ca8d 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -37,7 +37,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially, undo earlier changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially, undo earlier changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. 10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 43860dfd56..a13d273bc2 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -228,7 +228,7 @@ export class Include extends EntryTree { return { content, data } } - private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { + private applyPatches(data: EntryOptions[], patches?: PatchOptions[]): EntryOptions[] { return applyEntryPatches(data, patches, (message, ...args) => { this.ctx.root.logger?.('loader').warn(message, ...args) }) @@ -268,7 +268,7 @@ export class Include extends EntryTree { } private async apply(candidate: ReadCandidate) { - const data = this.applyPatches(candidate.data) + const data = this.applyPatches(candidate.data, this.config.patches) await this.root.update(data) this.content = candidate.content this.data = candidate.data From 0a297c39d3c005ca069d1b7bc95f606ca1e4e7b3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:23:16 +0800 Subject: [PATCH 012/114] fix(cordis): preserve concurrent loader composition --- .../2026-07-20-config-hot-reload-resilience.i18n.yaml | 4 ++-- .../bug-fix/2026-07-20-config-hot-reload-resilience.md | 6 +++--- .../bug-fix/2026-07-20-config-hot-reload-resilience.zh.md | 6 +++--- examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts | 4 +++- vendor/README.md | 2 +- vendor/loader/src/config/group.ts | 7 ++++++- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml index f6a6429e64..6f4a6d363d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.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/bug-fix/2026-07-20-config-hot-reload-resilience.md -2026-07-20-config-hot-reload-resilience.md: f3c36f8055179870c19c9d1ce99c3533fe602aa6 -2026-07-20-config-hot-reload-resilience.zh.md: 72ef2ebfa582dcc614198ed094c9b58ea1713460 +2026-07-20-config-hot-reload-resilience.md: 0f15bb0aaacb6e06c416cbe35b44155279497eee +2026-07-20-config-hot-reload-resilience.zh.md: 8a185c1915b5247150d8bb1dd5c42d69bd4f2a35 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md index f3c36f8055..0f15bb0aaa 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md @@ -14,7 +14,7 @@ The vendored Cordis lifecycle and Loader plugins provide an awaited, compensatin `Fiber.update()` returns its `internal/update` waterfall result. Config validation remains synchronous, while the default continuation returns the restart promise. Loader entry updates can therefore distinguish validation, import, application, and rollback failure from successful lifecycle settlement. `EntryTree.await()` rechecks service-gated fibers after Loader tasks drain and rejects settled failures; a fiber waiting on an absent service remains a valid pending entry rather than making settlement hang. -Loader imports a changed module name before disposing the active fiber. Candidate application is awaited; a failure disposes candidate effects and restores the prior plugin or config. Group reconciliation is sequential and restores earlier changed entries, additions, removals, and moves before rejecting. Persistence occurs only after successful programmatic mutation. This is a compensating transaction: lifecycle effects may be briefly visible, and a failed rollback is reported as an `AggregateError` rather than misrepresented as a retained tree. +Loader imports a changed module name before disposing the active fiber. Candidate application is awaited; a failure disposes candidate effects and restores the prior plugin or config. Group reconciliation starts candidates concurrently, awaits every outcome, and restores changed entries, additions, removals, and moves before rejecting. Persistence occurs only after successful programmatic mutation. This is a compensating transaction: lifecycle effects may be briefly visible, and a failed rollback is reported as an `AggregateError` rather than misrepresented as a retained tree. Include reads and validates detached candidate content, applies patches to a clone, reconciles the Loader tree, and only then commits cached content and parsed data. `refresh()` rejects to its caller after a parse, validation, application, or rollback failure. Initial load remains fail-loud; only an absent file may use `initial`. A non-array YAML/JSON result is invalid, and both file refresh and Include-config update re-apply patches without mutating the cached parse. @@ -26,7 +26,7 @@ HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` met **Restart the process for every config edit.** Rejected because Cordis effects already provide reversible plugin lifecycle, and a syntax error or failed optional plugin must not discard live sessions merely to recover the prior composition. -**Promise invisible atomic replacement.** Rejected because arbitrary plugin effects cannot be snapshotted. Sequential application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions. +**Promise invisible atomic replacement.** Rejected because arbitrary plugin effects cannot be snapshotted. Awaited application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions. ## Consequences @@ -38,4 +38,4 @@ HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` met ## Testing -`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, and `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer. +`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer, and the ACP `pty-tools` snapshot guards concurrent composition from reordering equal-priority prompt sections. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md index 72ef2ebfa5..8a185c1915 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md @@ -14,7 +14,7 @@ vendor 中的 Cordis 生命周期和 Loader 插件提供可等待、带补偿的 `Fiber.update()` 返回其 `internal/update` waterfall(瀑布式事件)的结果。配置校验保持同步,而默认 continuation 返回重启 promise。因此,Loader 配置项更新可以区分校验、导入、应用和回滚失败,以及生命周期成功完成。`EntryTree.await()` 会在 Loader 任务排空后重新检查受服务门控的 fiber,并在 fiber 已结算为失败时 reject;等待缺失服务的 fiber 仍是有效的 pending 配置项,不会让结算挂起。 -Loader 会先导入变化后的模块名,再 dispose(资源释放)活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect,并恢复先前的插件或配置。组内对账按顺序进行,并会在拒绝前恢复此前已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务:生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留。 +Loader 会先导入变化后的模块名,再 dispose(资源释放)活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect,并恢复先前的插件或配置。组内对账会并发启动各候选项,等待每项结果,并会在拒绝前恢复已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务:生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留。 Include 读取并校验尚未提交的候选内容,把补丁应用到其副本,对账 Loader 树,然后才提交缓存内容和解析数据。解析、校验、应用或回滚失败后,`refresh()` 会向调用方 reject。初始加载继续快速失败;只有文件不存在时才可以使用 `initial`。YAML/JSON 结果若不是数组即为无效;文件刷新和 Include 配置更新都会重新应用补丁,且不修改缓存的解析结果。 @@ -26,7 +26,7 @@ HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方 **每次编辑配置都重启进程。** 已否决,因为 Cordis effect 已经提供可逆的插件生命周期,而语法错误或可选插件失败不应只为恢复先前的组合就丢弃正在进行的会话。 -**承诺不可见的原子替换。** 已否决,因为任意插件 effect 无法制作快照。按顺序应用并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换。 +**承诺不可见的原子替换。** 已否决,因为任意插件 effect 无法制作快照。等待应用完成并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换。 ## Consequences @@ -38,4 +38,4 @@ HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方 ## Testing -`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject;`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除。 +`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject;`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除;ACP(Agent Client Protocol)的 `pty-tools` 快照会防止并发组合改变同优先级提示词段的顺序。 diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index cee30b4328..a713b27163 100644 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -184,6 +184,8 @@ describe('jsonrpc-agent keyless smoke', () => { expect(exitCode, stderr).toBe(1) expect(stdout).toBe('') - expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc') + expect(stderr).toContain('plugin tree failed to load') + expect(stderr).toContain('failed to apply loader entry jsonrpc (@deepseek-ai/dsh-jsonrpc)') + expect(stderr).toContain('sometimes') }, 30_000) }) diff --git a/vendor/README.md b/vendor/README.md index 8a1bb3ca8d..ad35767c33 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -37,7 +37,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially, undo earlier changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. 10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index cdd613caf6..8b96187275 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -68,7 +68,12 @@ export class EntryGroup { const newMap = Object.fromEntries(config.map(options => [options.id, options])) try { - for (const options of config) await this.create(options) + const outcomes = await Promise.allSettled(config.map(options => this.create(options))) + const failures = outcomes + .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') + .map(outcome => outcome.reason) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'loader entries failed to apply') for (const id of Object.keys(oldMap)) { if (!newMap[id]) await this.remove(id, true) } From f941ba1b5fe93149a0262972fb63478cfaf652a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:41:43 +0800 Subject: [PATCH 013/114] fix(review): label host-preparation failures and gate vendored lockfile links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot round 1: boot() now throws `host preparation failed` when prepare() rejects before any config-tree entry mounts (the plugin-tree label overstated), and the new hygiene gate verify-vendored-links pins the linkWorkspacePackages fix — every vendored package name in pnpm-lock.yaml must resolve to a workspace link with no registry copy alongside. --- package.json | 3 +- packages/ui/app-boot/src/index.ts | 13 +++- packages/ui/app-boot/tests/app-boot.spec.ts | 6 +- scripts/verify-vendored-links.ts | 72 +++++++++++++++++++++ vendor/README.md | 2 +- 5 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 scripts/verify-vendored-links.ts diff --git a/package.json b/package.json index a5f76a0612..c1013c23c6 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", + "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", @@ -100,7 +101,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --import tsx/esm apps/cli/src/bin.ts", diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 917eb777e8..7897c5fb89 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -445,7 +445,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. - * @throws a labelled load error after disposing the partial context. + * @throws a labelled error after disposing the partial context — `host + * preparation failed` when `prepare` threw before any config-tree entry + * mounted, `plugin tree failed to load` afterwards. */ export async function boot( binName: string, @@ -454,12 +456,16 @@ export async function boot( prepare?: (ctx: Context) => Promise | void, ): Promise { const ctx = new Context() + // Two failure labels: `prepare` runs before any config-tree entry mounts, + // so its failure is host setup, not the plugin tree. + let stage = 'host preparation failed' try { ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' ctx.provide('dshHomePath', dshHomePath) await ctx.plugin(Loader) ctx.loader.builtins.include = Include await prepare?.(ctx) + stage = 'plugin tree failed to load' // Pinned id: the bootstrap include is app glue, not a config row, and its // id appears in Loader failure chains — a random id would make startup // diagnostics unstable across runs (and snapshot fixtures). @@ -485,6 +491,9 @@ export async function boot( await assertEntriesActivated(ctx, binName) return ctx } catch (cause) { + // Root-fiber disposal contains cleanup failures per observer (Cordis + // fiber.ts hardening) and a repeated call returns the settled single-shot + // result, so this await cannot reject and replace `cause`. await ctx.fiber.dispose() const detail = cause instanceof Error ? cause.message : String(cause) // The transactional Loader wraps a failing entry apply in one message per @@ -495,7 +504,7 @@ export async function boot( let deepest: unknown = cause while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' - throw new Error(`${binName}: plugin tree failed to load: ${detail}${stack}`, { cause }) + throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause }) } } diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 03dd93a365..610f93b3d5 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -335,7 +335,7 @@ describe('boot', () => { }) await expect(task).rejects.toMatchObject({ - message: `${NAME}: plugin tree failed to load: ${failure}`, + message: `${NAME}: host preparation failed: ${failure}`, cause: failure, }) expect(disposed).toBe(true) @@ -418,9 +418,9 @@ describe('boot', () => { const deepest = new Error('stackless deep failure') delete (deepest as { stack?: string }).stack await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => { - throw new Error('host preparation failed', { cause: deepest }) + throw new Error('wrapped setup failure', { cause: deepest }) })).rejects.toThrow( - `${NAME}: plugin tree failed to load: host preparation failed\nstackless deep failure`, + `${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`, ) }) diff --git a/scripts/verify-vendored-links.ts b/scripts/verify-vendored-links.ts new file mode 100644 index 0000000000..93b8390157 --- /dev/null +++ b/scripts/verify-vendored-links.ts @@ -0,0 +1,72 @@ +/** + * Verify that pnpm-lock.yaml resolves every vendored package name to its + * workspace `link:` — never a registry copy. `linkWorkspacePackages: true` + * (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the + * pinned vendored sources; a registry copy of the same name coexisting with + * the vendored one silently forks the framework layer (vendor/README.md). + */ +import { readdir, readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import * as yaml from 'js-yaml' + +const root = resolve(import.meta.dirname, '..') + +async function vendoredNames(): Promise> { + const names = new Set() + for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) { + if (!entry.isDirectory()) continue + let manifest: { name?: string } + try { + manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string } + } catch { + continue // not a package directory (e.g. vendor/README.md siblings) + } + if (manifest.name !== undefined) names.add(manifest.name) + } + return names +} + +interface Lockfile { + importers?: Record> + packages?: Record + snapshots?: Record +} + +const names = await vendoredNames() +if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/') +const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile + +const violations: string[] = [] + +// Importer resolutions: every dependency entry naming a vendored package must +// resolve to a link:, or the build silently uses a registry copy. +for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) { + for (const [section, dependencies] of Object.entries(sections)) { + if (typeof dependencies !== 'object' || dependencies === null) continue + for (const [dependency, entry] of Object.entries(dependencies as Record)) { + if (!names.has(dependency)) continue + const version = entry.version ?? '' + if (!version.startsWith('link:')) { + violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`) + } + } + } +} + +// Package/snapshot keys: a registry copy materializes as a `@` +// key; vendored names must never appear there at all. +for (const section of ['packages', 'snapshots'] as const) { + for (const key of Object.keys(lockfile[section] ?? {})) { + const atIndex = key.lastIndexOf('@') + if (atIndex <= 0) continue + const packageName = key.slice(0, atIndex) + if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`) + } +} + +if (violations.length > 0) { + console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`) + for (const violation of violations) console.error(` - ${violation}`) + process.exit(1) +} +console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`) diff --git a/vendor/README.md b/vendor/README.md index ad35767c33..443c78e278 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -2,7 +2,7 @@ This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned). -All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. +All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md). From fd4d3699076071e5de3e965d5160400e9a57fabc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:45:49 +0800 Subject: [PATCH 014/114] feat(cordis): add repository package cache --- ...-manager-native-repository-cache.i18n.yaml | 6 + ...package-manager-native-repository-cache.md | 47 +++++ ...kage-manager-native-repository-cache.zh.md | 47 +++++ ...07-17-sdk-follow-up-capabilities.i18n.yaml | 6 +- .../2026-07-17-sdk-follow-up-capabilities.md | 2 + ...026-07-17-sdk-follow-up-capabilities.zh.md | 2 + THIRD_PARTY_NOTICES.md | 1 + .../app-boot/tests/repository-cache.spec.ts | 144 +++++++++++++ pnpm-lock.yaml | 10 + tsconfig.base.json | 1 + vendor/README.md | 5 +- vendor/loader/package.json | 8 +- vendor/loader/src/repository.ts | 191 ++++++++++++++++++ vendor/loader/tsdown.config.ts | 18 ++ 14 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md create mode 100644 .agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md create mode 100644 packages/ui/app-boot/tests/repository-cache.spec.ts create mode 100644 vendor/loader/src/repository.ts create mode 100644 vendor/loader/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml new file mode 100644 index 0000000000..6c888c7cad --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.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/architecture/2026-07-30-package-manager-native-repository-cache.md +2026-07-30-package-manager-native-repository-cache.md: f8a6706065a936ca4a9abf2a50d266a60f09b252 +2026-07-30-package-manager-native-repository-cache.zh.md: b1fea3d655f8d7aeb466744dc27bbf4ba69993ec diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md new file mode 100644 index 0000000000..f8a6706065 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md @@ -0,0 +1,47 @@ +# Agent Note: Package-manager-native repository cache + +Status: implemented + +English | [中文](2026-07-30-package-manager-native-repository-cache.zh.md) + +## Problem + +A standalone Harness app cannot rely on a developer-owned SDK project to declare and install repository dependencies. Loading a configured GitHub repository therefore needs a persistent fetch, preparation, and cache boundary, but implementing Git transport, hosted-source syntax, package preparation, and a content store inside DSH would duplicate a package manager. Requiring a separately installed package manager would make a config-only feature depend on host setup. + +The cache also needs an update identity. A mutable branch name cannot both remain permanently cached and reflect later commits without an independent refresh protocol. + +## Decision + +Vendored `@cordisjs/plugin-loader/repository` exports `RepositoryCache`, a generic Node-only package helper with no DSH plugin-format knowledge. Keeping it on a subpath prevents browser consumers of the Loader's main entry from traversing Node filesystem and child-process imports. The caller supplies a package-manager-native source specifier and a cache root. DSH-specific callers own accepted source syntax, path selection, and the cache-root location; the [SDK project dependency workflow](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md#external-cordis-plugin-installation) remains a separate path owned by the developer project's selected package manager. + +The Loader carries an exact runtime dependency on `pnpm@11.7.0` and invokes that package's JavaScript entry with the current Node executable. It never discovers a global executable or delegates through Corepack. Each cache miss creates an isolated project with one dependency named `repository`; pnpm owns Git/GitHub resolution, fetching, its content-addressed store, dependency installation, and lifecycle scripts in the repository's dependency graph. + +The isolated workspace sets `dangerouslyAllowAllBuilds: true`. A configured repository and its dependency graph are trusted executable code: lifecycle scripts may run before DSH reads any declared assets. The child receives ordinary host process state needed by Git and pnpm, but ambient credential-shaped (`KEY`, `PASSWORD`, `SECRET`, `TOKEN`) variables are removed. No OAuth, token forwarding, or private-repository authentication contract is added. + +The SHA-256 of the exact specifier names the cache entry. Concurrent same-process requests share one task. Installation occurs in a sibling temporary directory; only a successful install with a package directory and marker is atomically renamed into the final key. Failed staging is removed, and a competing process's already-published valid entry wins. A later process validates the marker and package directory before returning the stable `node_modules/repository` path. + +An identical specifier permanently reuses its published entry. The caller changes the ref or another part of the specifier to request a new generation; the cache does not poll remotes, reinterpret mutable refs, expire entries, or garbage-collect old generations. + +## Alternatives considered + +**Implement GitHub download, archive extraction, preparation, and caching directly.** Rejected under the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md): pnpm already owns hosted Git syntax, Git execution, lifecycle policy, and a shared content store. A second resolver would add more code while still needing package semantics. + +**Require `pnpm` on `PATH` or invoke Corepack.** Rejected because changing one app config must be sufficient on every supported installation. Pinning and shipping the CLI also makes the preparation policy reviewable and independent of the host's package-manager version. + +**Resolve a branch or tag again on every startup.** Rejected because it turns startup into a network refresh, changes code without a config diff, and makes rollback depend on remote state. Explicit ref changes preserve auditability even when a user deliberately chooses a mutable ref. + +**Disable repository lifecycle scripts.** Rejected because common plugin repositories need a declarative `prepare` step to validate and package their plugin subdirectory. The trust boundary is explicit configuration of executable source, not an incomplete illusion that only static files can run. + +**Introduce a Cordis repository service.** Rejected because cache lookup has no runtime contribution registry or provider variation. A small helper lets the later host own Cordis lifecycle and HMR without adding a service seam prematurely. + +## Consequences + +- Standalone apps carry pnpm's approximately 18.6 MB unpacked runtime instead of requiring a global tool or owning a Git/package implementation. +- A repository author may use ordinary package preparation, and a malicious configured repository or dependency can execute code with the scrubbed child environment and the user's filesystem authority. +- Exact specifiers make startup deterministic after the first successful install; changing cached code requires a config/ref change. +- Failed installs leave no published cache entry and may be retried. Published corruption fails loud instead of silently reinstalling under the same identity. +- Cache generations consume disk until a future explicit cache-management policy removes them. + +## Testing + +`packages/ui/app-boot/tests/repository-cache.spec.ts` covers same-process single-flight, cross-instance cache reuse, exact-specifier separation, failed-stage cleanup and retry, and boundary validation. Its real local-Git case invokes the bundled pnpm, runs the fixture repository's `prepare` script, and reads the prepared file from the installed cache entry without network access. diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md new file mode 100644 index 0000000000..b1fea3d655 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 包管理器原生仓库缓存 + +Status: implemented + +[English](2026-07-30-package-manager-native-repository-cache.md) | 中文 + +## 问题 + +独立运行的 Harness 应用不能依赖开发者自有的 SDK 工程来声明并安装仓库依赖。因此,加载配置中的 GitHub 仓库需要一道持久的获取、准备与缓存边界;但如果在 DSH 内实现 Git 传输、托管来源语法、包(package)准备流程和内容存储,就会重复实现包管理器。若要求用户另行安装包管理器,则只需修改配置即可使用的功能还会依赖宿主环境的额外配置。 + +缓存还需要明确更新标识。若没有独立的刷新协议,可变分支名无法既永久缓存,又反映后续 commit。 + +## 决策 + +vendor 中的 `@cordisjs/plugin-loader/repository` 导出 `RepositoryCache`:一个不包含 DSH 插件格式知识、仅限 Node 使用的通用包辅助工具。把它保留在子路径上,可以避免 Loader 主入口的浏览器消费方在解析依赖时遍历到 Node 文件系统和子进程 import。调用方提供包管理器原生的来源 specifier 和缓存根目录。DSH 专属调用方负责规定可接受的来源语法、路径选择与缓存根目录位置;[SDK 工程依赖工作流](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md#external-cordis-plugin-installation)仍是另一条路径,由开发者工程选定的包管理器负责。 + +Loader 将 `pnpm@11.7.0` 作为固定版本的运行时依赖,并使用当前 Node 可执行文件调用该包的 JavaScript 入口。它绝不探测全局可执行文件,也不经 Corepack 调用。每次缓存未命中都会创建一个隔离工程,其中只有一个名为 `repository` 的依赖;Git 与 GitHub 来源的解析和获取、pnpm 自身的内容寻址 store、依赖安装,以及仓库依赖图中的生命周期脚本均由 pnpm 负责。 + +隔离工作区设置 `dangerouslyAllowAllBuilds: true`。用户配置的仓库及其依赖图都属于受信任的可执行代码:DSH 读取任何已声明资产之前,生命周期脚本就可能运行。子进程会收到 Git 与 pnpm 所需的常规宿主进程状态,但会移除环境中名称形似凭据(`KEY`、`PASSWORD`、`SECRET`、`TOKEN`)的变量。该机制不新增 OAuth、token 转发或私有仓库认证契约。 + +缓存项以精确 specifier 的 SHA-256 命名。同一进程内针对相同 specifier 的并发请求共享一项任务。安装在同级临时目录中进行;只有安装成功且存在包目录和标记时,系统才会把暂存目录原子重命名为最终键对应的目录。失败的暂存目录会被删除;如果另一进程已发布有效项,则以该项为准。后续进程会先校验标记与包目录,再返回稳定的 `node_modules/repository` 路径。 + +相同的 specifier 会永久复用已发布项。调用方通过修改 ref 或 specifier 的其他部分来请求新的缓存代次;缓存不会轮询远端、重新解释可变 ref、让条目过期,也不会垃圾回收旧代次。 + +## 曾考虑的替代方案 + +**直接实现 GitHub 下载、归档解压、准备与缓存。** 根据[依赖政策](../process/2026-07-26-dependencies-over-hand-rolling.md)不予采纳:pnpm 已负责托管 Git 语法、Git 执行、生命周期政策和共享内容存储。第二套解析器会增加更多代码,却仍需实现包语义。 + +**要求 `pnpm` 位于 `PATH` 上,或调用 Corepack。** 不予采纳:在每种受支持的安装形态中,只修改一份应用配置就必须足以启用该功能。固定并随应用分发 CLI(命令行界面)还能使准备政策可供评审,并与宿主的包管理器版本无关。 + +**每次启动都重新解析分支或 tag。** 不予采纳:这会把启动变成网络刷新,在配置 diff 未变化时更改代码,并让回滚依赖远端状态。即使用户有意选择可变 ref,显式修改 ref 仍能保持可审计性。 + +**禁用仓库生命周期脚本。** 不予采纳:常见插件仓库需要声明式 `prepare` 步骤来校验并打包插件子目录。信任边界是显式配置可执行来源,而不是营造一种不完整的假象,仿佛只有静态文件能够运行。 + +**引入 Cordis 仓库服务。** 不予采纳:缓存查找没有运行时贡献注册表,也不存在提供方变体。小型 helper 让后续宿主负责 Cordis 生命周期与 HMR(热模块替换),无需过早新增服务 seam。 + +## 后果 + +- 独立应用随附 pnpm 约 18.6 MB 的解压后运行时,不要求全局工具,也无需自行实现 Git 与包处理。 +- 仓库作者可以使用常规包准备流程;恶意的已配置仓库或依赖可以在经过上述清理的子进程环境中,以用户的文件系统权限执行代码。 +- 精确 specifier 使首次安装成功后的启动具有确定性;更改缓存代码必须修改配置或 ref。 +- 安装失败不会留下已发布缓存项,可以再次重试。已发布缓存损坏时会明确报错,而不会在同一标识下静默重装。 +- 缓存代次会持续占用磁盘,直到未来有明确的缓存管理政策将其移除。 + +## 测试 + +`packages/ui/app-boot/tests/repository-cache.spec.ts` 覆盖同进程 single-flight、跨实例缓存复用、精确 specifier 隔离、失败暂存清理与重试,以及边界校验。其真实本地 Git 用例会调用随附的 pnpm,运行 fixture(测试前置数据)仓库的 `prepare` 脚本,并在不访问网络的情况下,从已安装缓存项中读取准备后的文件。 diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml index 8b70484312..fab3ecc2b1 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-17-sdk-follow-up-capabilities.md: 0f3ada6bdbb4ce933d14602cf59be9a51640e61c -2026-07-17-sdk-follow-up-capabilities.zh.md: d0d0b3e6bcdf192e64f003dc9f6e90cc2bdb060b +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md +2026-07-17-sdk-follow-up-capabilities.md: 88d5d2f9bd1ce01c20177bcaee5bbe6b434bb978 +2026-07-17-sdk-follow-up-capabilities.zh.md: 998b7ec3cfddafe40537908fb61aa6d7e6f90418 diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md index 0f3ada6bdb..88d5d2f9bd 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md @@ -47,6 +47,8 @@ The repository ships a thin `SKILL.md` that teaches an agent to construct the st The package manager owns source parsing, version or commit resolution, integrity data, lockfile updates, and any build policy. The SDK does not download or unpack a second copy through giget or pacote. An external plugin remains a dependency under `node_modules`; local plugin scaffolding remains a separate project-creation concern. +This proposal concerns dependencies of developer-owned SDK projects. Standalone app repository caching, its bundled-pnpm policy, and its explicit preparation trust boundary are owned by the [package-manager-native repository cache](../../implemented/architecture/2026-07-30-package-manager-native-repository-cache.md). + ## Launcher telemetry ### Consent and collection diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md index d0d0b3e6bc..998b7ec3cf 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md @@ -47,6 +47,8 @@ Create 和 config 使用相同的功能计划形状。create 通过上述命令 包管理器负责来源解析、版本或 commit 解析、`integrity` 数据、lockfile 更新和构建策略。SDK 不再通过 giget 或 pacote 下载、解压第二份副本。外部插件是 `node_modules` 下的依赖;本地插件脚手架仍属于独立的工程创建问题。 +本提案只涉及开发者自有 SDK 工程的依赖。独立应用的仓库缓存、随应用捆绑 pnpm 的政策和显式的准备流程信任边界,均由[包管理器原生仓库缓存](../../implemented/architecture/2026-07-30-package-manager-native-repository-cache.md)负责。 + ## Launcher 遥测 ### Consent 与采集 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 754ae93d82..92ea0d2406 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -66,6 +66,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | +| [`pnpm`](https://github.com/pnpm/pnpm) | MIT | | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | | [`react-markdown`](https://github.com/remarkjs/react-markdown) | MIT | diff --git a/packages/ui/app-boot/tests/repository-cache.spec.ts b/packages/ui/app-boot/tests/repository-cache.spec.ts new file mode 100644 index 0000000000..33a223020f --- /dev/null +++ b/packages/ui/app-boot/tests/repository-cache.spec.ts @@ -0,0 +1,144 @@ +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@cordisjs/plugin-loader/repository' + +const execFileAsync = promisify(execFile) +const roots: string[] = [] + +async function temporaryRoot(name: string): Promise { + const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`)) + roots.push(root) + return root +} + +async function fakePackage(directory: string): Promise { + const target = join(directory, 'node_modules', 'repository') + await mkdir(target, { recursive: true }) + await writeFile(join(target, 'package.json'), '{"name":"fixture"}\n') +} + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('RepositoryCache', () => { + it('single-flights and permanently reuses an exact specifier', async () => { + const root = await temporaryRoot('repository-cache') + const calls: string[] = [] + const install: RepositoryInstall = async (directory) => { + calls.push(directory) + await fakePackage(directory) + } + const cache = new RepositoryCache(root, install) + const specifier = 'github:owner/repository#0123456789abcdef' + + const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)]) + expect(concurrent).toBe(first) + expect(calls).toHaveLength(1) + + const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') }) + expect(await reopened.resolve(specifier)).toBe(first) + expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({ + packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`, + dependencies: { repository: specifier }, + }) + + const second = await cache.resolve('github:owner/repository#fedcba9876543210') + expect(second).not.toBe(first) + expect(calls).toHaveLength(2) + }) + + it('accepts the valid winner when independent cache instances race', async () => { + const root = await temporaryRoot('repository-race') + const bothStarted = Promise.withResolvers() + let starts = 0 + const install: RepositoryInstall = async (directory) => { + await fakePackage(directory) + starts += 1 + if (starts === 2) bothStarted.resolve(undefined) + await bothStarted.promise + } + const specifier = 'github:owner/repository#race' + + const [first, second] = await Promise.all([ + new RepositoryCache(root, install).resolve(specifier), + new RepositoryCache(root, install).resolve(specifier), + ]) + + expect(second).toBe(first) + expect(starts).toBe(2) + expect(await readdir(root)).toHaveLength(1) + }) + + it('removes a failed staging tree and permits an exact retry', async () => { + const root = await temporaryRoot('repository-retry') + let attempts = 0 + const cache = new RepositoryCache(root, async (directory) => { + attempts += 1 + if (attempts === 1) throw new Error('install failed') + await fakePackage(directory) + }) + + await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository') + expect(await readdir(root)).toEqual([]) + await expect(cache.resolve('github:owner/repository#ref')).resolves.toContain('node_modules') + expect(attempts).toBe(2) + }) + + it('rejects empty or padded specifiers before touching the cache', async () => { + const root = await temporaryRoot('repository-input') + const cache = new RepositoryCache(root, fakePackage) + expect(() => cache.resolve('')).toThrow('non-empty unpadded string') + expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string') + await expect(readdir(root)).resolves.toEqual([]) + }) + + it('fails loud on a corrupt published marker instead of reinstalling it', async () => { + const root = await temporaryRoot('repository-corrupt') + const specifier = 'github:owner/repository#corrupt' + const key = createHash('sha256').update(specifier).digest('hex') + const entry = join(root, key) + await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true }) + await writeFile(join(entry, '.repository-cache.json'), '{}\n') + const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') }) + + await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid') + }) + + it('runs a Git dependency prepare script through the bundled pnpm', { timeout: 60_000 }, async () => { + const root = await temporaryRoot('repository-pnpm') + const repository = join(root, 'source') + await mkdir(repository) + await writeFile(join(repository, 'package.json'), `${JSON.stringify({ + name: 'repository-fixture', + version: '1.0.0', + scripts: { prepare: 'node prepare.mjs' }, + })}\n`) + await writeFile(join(repository, 'prepare.mjs'), [ + "import { writeFile } from 'node:fs/promises'", + "await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)", + '', + ].join('\n')) + await execFileAsync('git', ['init', '--quiet'], { cwd: repository }) + await execFileAsync('git', ['add', '.'], { cwd: repository }) + await execFileAsync('git', [ + '-c', 'user.name=Repository Fixture', + '-c', 'user.email=repository@example.invalid', + 'commit', '--quiet', '-m', 'fixture', + ], { cwd: repository }) + const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' }) + const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}` + vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible') + vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden') + + const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) + await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cefd39d0ee..e7f8fd6148 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6548,6 +6548,9 @@ importers: node-addon-require-builtin: specifier: ^0.1.3 version: 0.1.3 + pnpm: + specifier: 11.7.0 + version: 11.7.0 vendor/logger-console: dependencies: @@ -10951,6 +10954,11 @@ packages: engines: {node: '>=18'} hasBin: true + pnpm@11.7.0: + resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==} + engines: {node: '>=22.13'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -16387,6 +16395,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pnpm@11.7.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/tsconfig.base.json b/tsconfig.base.json index 2d078202e7..94b60804e4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -32,6 +32,7 @@ "cosmokit": ["./vendor/cosmokit/src"], "schemastery": ["./vendor/schemastery/src"], "@cordisjs/plugin-loader": ["./vendor/loader/src"], + "@cordisjs/plugin-loader/repository": ["./vendor/loader/src/repository.ts"], "@cordisjs/plugin-include": ["./vendor/include/src"], "@cordisjs/plugin-group": ["./vendor/group/src"], "@cordisjs/plugin-timer": ["./vendor/timer/src"], diff --git a/vendor/README.md b/vendor/README.md index 443c78e278..3872faa753 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,8 +39,9 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. -10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. -11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. +10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm. +11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. +12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. ## Sync procedure diff --git a/vendor/loader/package.json b/vendor/loader/package.json index c7bbaf5176..e24e0657a6 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./repository": { + "types": "./lib/types/repository.d.ts", + "default": "./lib/repository.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/repository.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -32,6 +37,7 @@ } }, "dependencies": { - "cosmokit": "^1.8.1" + "cosmokit": "^1.8.1", + "pnpm": "11.7.0" } } diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts new file mode 100644 index 0000000000..94a0c5cf16 --- /dev/null +++ b/vendor/loader/src/repository.ts @@ -0,0 +1,191 @@ +/** + * Exact-specifier repository packages installed through the Loader's bundled + * pnpm. The caller owns source validation and the cache root; this module owns + * isolated installation, single-flight reuse, and atomic cache publication. + */ + +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +/** Exact pnpm release shipped with the Loader for repository installation. */ +export const BUNDLED_PNPM_VERSION = '11.7.0' + +const DEPENDENCY_NAME = 'repository' +const MARKER_NAME = '.repository-cache.json' +const MAX_ERROR_OUTPUT = 32 * 1024 +const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i + +/** Injectable isolated-install boundary used by {@link RepositoryCache}. */ +export type RepositoryInstall = (directory: string) => Promise + +interface CacheMarker { + specifier: string +} + +function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name))) +} + +function appendOutput(current: string, chunk: Uint8Array): string { + const combined = current + Buffer.from(chunk).toString('utf8') + return combined.length <= MAX_ERROR_OUTPUT ? combined : combined.slice(-MAX_ERROR_OUTPUT) +} + +async function installWithBundledPnpm(directory: string): Promise { + const require = createRequire(import.meta.url) + const pnpmManifest = require.resolve('pnpm') + const pnpmBin = join(dirname(pnpmManifest), 'bin', 'pnpm.mjs') + let output = '' + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + const child = spawn(process.execPath, [ + pnpmBin, + 'install', + '--no-frozen-lockfile', + '--reporter=append-only', + ], { + cwd: directory, + env: scrubEnvironment(), + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }) + child.stdout.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) }) + child.stderr.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) }) + child.once('error', reject) + child.once('close', (code, signal) => { resolve({ code, signal }) }) + }) + if (result.signal !== null) { + throw new Error(`bundled pnpm install was killed by ${result.signal}${output ? `\n${output.trimEnd()}` : ''}`) + } + if (result.code !== 0) { + throw new Error(`bundled pnpm install exited with code ${String(result.code)}${output ? `\n${output.trimEnd()}` : ''}`) + } +} + +function cacheKey(specifier: string): string { + return createHash('sha256').update(specifier).digest('hex') +} + +async function readCached(directory: string, specifier: string): Promise { + let content: string + try { + content = await readFile(join(directory, MARKER_NAME), 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + let parsed: unknown + try { + parsed = JSON.parse(content) as unknown + } catch (error) { + throw new Error(`repository cache marker is invalid: ${join(directory, MARKER_NAME)}`, { cause: error }) + } + if (typeof parsed !== 'object' || parsed === null || typeof (parsed as Partial).specifier !== 'string') { + throw new Error(`repository cache marker is invalid: ${join(directory, MARKER_NAME)}`) + } + const marker = parsed as CacheMarker + if (marker.specifier !== specifier) { + throw new Error(`repository cache key collision for ${JSON.stringify(specifier)}`) + } + const packageDirectory = join(directory, 'node_modules', DEPENDENCY_NAME) + let packageStat + try { + packageStat = await stat(packageDirectory) + } catch (error) { + throw new Error(`repository cache entry is incomplete: ${directory}`, { cause: error }) + } + if (!packageStat.isDirectory()) throw new Error(`repository cache package is not a directory: ${packageDirectory}`) + return packageDirectory +} + +async function removeStaging(directory: string, cause: unknown): Promise { + try { + await rm(directory, { recursive: true, force: true }) + } catch (cleanupError) { + throw new AggregateError([cause, cleanupError], `failed to clean repository staging directory ${directory}`) + } + throw cause +} + +/** + * Persistent exact-specifier package cache backed by bundled pnpm. + * + * One isolated project contains one dependency named `repository`. A successful + * install is atomically renamed into its SHA-256 key, so failed installs never + * become cache hits. The exact specifier is immutable: callers change the + * specifier (normally its Git ref) to request another generation. + */ +export class RepositoryCache { + /** Absolute directory containing immutable repository cache entries. */ + readonly directory: string + + private readonly tasks = new Map>() + + /** + * @param directory - caller-owned persistent cache root. + * @param install - isolated package installation boundary; defaults to the bundled pnpm. + */ + constructor(directory: string, private readonly install: RepositoryInstall = installWithBundledPnpm) { + this.directory = resolve(directory) + } + + /** + * Resolve one package-manager-native dependency specifier to its installed package directory. + * @param specifier - exact immutable dependency specifier used as the permanent cache identity. + * @returns the installed `repository` dependency directory. + * @throws when the specifier is empty/padded, installation fails, or a published cache entry is corrupt. + */ + resolve(specifier: string): Promise { + if (!specifier || specifier.trim() !== specifier) { + throw new TypeError('repository specifier must be a non-empty unpadded string') + } + const existing = this.tasks.get(specifier) + if (existing) return existing + const task = this.resolveUncached(specifier).finally(() => { + if (this.tasks.get(specifier) === task) this.tasks.delete(specifier) + }) + this.tasks.set(specifier, task) + return task + } + + private async resolveUncached(specifier: string): Promise { + const finalDirectory = join(this.directory, cacheKey(specifier)) + const cached = await readCached(finalDirectory, specifier) + if (cached) return cached + + await mkdir(this.directory, { recursive: true }) + const staging = await mkdtemp(join(this.directory, '.repository-')) + try { + await writeFile(join(staging, 'package.json'), `${JSON.stringify({ + name: 'cordis-repository-cache-entry', + private: true, + version: '0.0.0', + packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`, + dependencies: { [DEPENDENCY_NAME]: specifier }, + }, undefined, 2)}\n`) + await writeFile(join(staging, 'pnpm-workspace.yaml'), [ + 'packages: []', + 'dangerouslyAllowAllBuilds: true', + '', + ].join('\n')) + await this.install(staging) + const packageDirectory = join(staging, 'node_modules', DEPENDENCY_NAME) + const packageStat = await stat(packageDirectory) + if (!packageStat.isDirectory()) throw new Error(`installed repository is not a directory: ${packageDirectory}`) + await writeFile(join(staging, MARKER_NAME), `${JSON.stringify({ specifier })}\n`) + try { + await rename(staging, finalDirectory) + } catch (error) { + const winner = await readCached(finalDirectory, specifier) + if (!winner) throw error + await rm(staging, { recursive: true, force: true }) + return winner + } + } catch (error) { + return removeStaging(staging, new Error(`failed to prepare repository ${JSON.stringify(specifier)}`, { cause: error })) + } + return (await readCached(finalDirectory, specifier))! + } +} diff --git a/vendor/loader/tsdown.config.ts b/vendor/loader/tsdown.config.ts new file mode 100644 index 0000000000..75e627cdd2 --- /dev/null +++ b/vendor/loader/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** Keep the browser-reachable Loader entry separate from the Node-only repository cache. */ +const shared = { + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + outputOptions: { codeSplitting: false }, + dts: false, + clean: false, +} as const + +export default defineConfig([ + { ...shared, entry: ['lib/types/index.js'] }, + { ...shared, entry: ['lib/types/repository.js'] }, +]) From fa7051a9d1b6a35fc037b464d71742ed0e391329 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:15:07 +0800 Subject: [PATCH 015/114] feat: add static repository plugin format --- ...-static-repository-plugin-format.i18n.yaml | 6 + ...6-07-30-static-repository-plugin-format.md | 49 ++++ ...7-30-static-repository-plugin-format.zh.md | 49 ++++ docs/config-catalog.md | 5 + docs/module-graph.md | 5 + .../tests/fixtures/cli.cordis.yml | 10 + .../skills/0/repository-fixture/SKILL.md | 6 + .../fixtures/repository-plugin/dsh-plugin.mjs | 9 + .../headless-agent/tests/keyless-smoke.e2e.ts | 11 + examples/package.json | 1 + packages/README.i18n.yaml | 4 +- packages/README.md | 2 +- packages/README.zh.md | 2 +- packages/cordis/README.i18n.yaml | 4 +- packages/cordis/README.md | 5 +- packages/cordis/README.zh.md | 5 +- .../cordis/repository-plugin/README.i18n.yaml | 6 + packages/cordis/repository-plugin/README.md | 85 ++++++ .../cordis/repository-plugin/README.zh.md | 85 ++++++ .../cordis/repository-plugin/package.json | 53 ++++ packages/cordis/repository-plugin/src/bin.ts | 12 + .../cordis/repository-plugin/src/format.ts | 168 ++++++++++++ .../cordis/repository-plugin/src/index.ts | 97 +++++++ .../cordis/repository-plugin/src/invariant.ts | 30 +++ packages/cordis/repository-plugin/src/mcp.ts | 145 +++++++++++ .../tests/mcp-format.spec.ts | 109 ++++++++ .../tests/repository-plugin.spec.ts | 243 ++++++++++++++++++ .../cordis/repository-plugin/tsconfig.json | 30 +++ .../cordis/repository-plugin/tsdown.config.ts | 17 ++ packages/skill/skill-local/README.i18n.yaml | 4 +- packages/skill/skill-local/README.md | 4 +- packages/skill/skill-local/README.zh.md | 4 +- packages/skill/skill-local/src/index.ts | 37 ++- pnpm-lock.yaml | 34 +++ tsconfig.host.json | 1 + 35 files changed, 1310 insertions(+), 27 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md create mode 100644 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md create mode 100644 examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md create mode 100644 examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs create mode 100644 packages/cordis/repository-plugin/README.i18n.yaml create mode 100644 packages/cordis/repository-plugin/README.md create mode 100644 packages/cordis/repository-plugin/README.zh.md create mode 100644 packages/cordis/repository-plugin/package.json create mode 100644 packages/cordis/repository-plugin/src/bin.ts create mode 100644 packages/cordis/repository-plugin/src/format.ts create mode 100644 packages/cordis/repository-plugin/src/index.ts create mode 100644 packages/cordis/repository-plugin/src/invariant.ts create mode 100644 packages/cordis/repository-plugin/src/mcp.ts create mode 100644 packages/cordis/repository-plugin/tests/mcp-format.spec.ts create mode 100644 packages/cordis/repository-plugin/tests/repository-plugin.spec.ts create mode 100644 packages/cordis/repository-plugin/tsconfig.json create mode 100644 packages/cordis/repository-plugin/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml new file mode 100644 index 0000000000..0190e19f8e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.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/architecture/2026-07-30-static-repository-plugin-format.md +2026-07-30-static-repository-plugin-format.md: c4739a6843db515d4cd67441e74bbf05228c612a +2026-07-30-static-repository-plugin-format.zh.md: 0b0a2af132137a820ba941869a612b7f28754fcb diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md new file mode 100644 index 0000000000..c4739a6843 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -0,0 +1,49 @@ +# Agent Note: Static repository Plugin format + +Status: implemented + +English | [中文](2026-07-30-static-repository-plugin-format.zh.md) + +## Problem + +A repository that already contains reusable skills or an MCP server declaration should be usable by standalone Harness applications without becoming a Harness SDK project or rewriting its existing layout. Popular repositories must be able to add one `.dsh-plugin` directory while keeping their current skills and `.mcp.json` elsewhere in the tree. At the same time, treating an arbitrary repository entry point as a Cordis Plugin would make every repository a new unrestricted runtime extension surface and would bypass the existing skill and MCP lifecycle owners. + +The [package-manager-native repository cache](2026-07-30-package-manager-native-repository-cache.md) prepares an exact package source but intentionally knows nothing about DSH formats. This layer therefore needs a package-manager-compatible authoring format, a deterministic prepared artifact, and a Cordis composition that stays transactional under Loader disposal and replacement. + +## Decision + +`@deepseek-ai/dsh-repository-plugin` owns a restricted `.dsh-plugin` package format with two contribution kinds only: skill roots and one common `.mcp.json`. Its package metadata uses `package.json#dsh.skills` for relative skill-root paths and `package.json#dsh.mcpServers` for the relative MCP document path. At least one is required. Each path may leave `.dsh-plugin` to reuse repository content but must remain beneath the directory containing that `.dsh-plugin`; a nested selectable Plugin therefore owns the adjacent subtree above its package without gaining access to unrelated host paths. + +The `.dsh-plugin` package declares `dsh-plugin-prepare` as its ordinary package-manager `prepare` script. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The `.mjs` extension avoids imposing `type: module` on repository-authored package metadata. The generated module is a fixed import-free template containing only a normalized manifest, `inject = ['loader']`, and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point. + +Loading the DSH package registers that builtin as an effect. A generated wrapper mounts the builtin as its child with `import.meta.url`, so all contributions belong to the wrapper fiber and disappear on Loader removal or rollback. The builtin revalidates the prepared manifest and path containment before reading assets. It composes the existing implementations rather than registering skills or MCP tools itself. + +Each prepared skill set mounts `dsh-skill-local` with a unique `repository:` provider name, only the copied custom roots, and watching disabled. `dsh-skill-local` therefore gains two general configuration fields: `providerName` and `includeDefaultRoots`. Their defaults preserve its existing single local provider; repository instances set a distinct name and exclude project/user roots so multiple instances neither collide nor duplicate host-local discovery. + +Each `.mcp.json` server becomes one existing `dsh-mcp-client` child. The adapter accepts the common root `{ "mcpServers": ... }`; stdio definitions allow only optional `type: "stdio"`, `command`, `args`, and `env`, while HTTP definitions allow only `type: "http"`, `url`, and `headers`. Exact `${NAME}` process-environment references expand at runtime, after cache preparation; missing names fail Plugin load. HTTP maps to the client's Streamable HTTP transport, and stdio uses the prepared package directory as `cwd`. The existing client alone owns connection attempts, failure logging, remote tool synchronization, tool calls, and disconnects. Consequently an MCP connection failure keeps its established successful-plugin/no-tools behavior and is not reclassified as a repository preparation or Loader failure. + +Unknown MCP fields reject. This intentionally excludes OAuth, `auth` objects, `CLAUDE_PLUGIN_ROOT`, and a broader Claude compatibility contract. Hooks, commands, agents, apps, arbitrary Cordis code, marketplaces, and discovery are also unsupported. Repository subdirectory selection and GitHub configuration belong to the later app/cache integration, not this format package. + +## Alternatives considered + +**Load a repository's own Cordis entry point.** Rejected because it makes the advertised static format an unrestricted code-loading API, requires repository authors to depend on Harness internals, and duplicates the ordinary SDK/plugin-dependency path. + +**Teach generated wrappers to implement skills and MCP directly.** Rejected because copied runtime code would drift from `dsh-skill-local` and `dsh-mcp-client`, especially their provider invalidation, tool synchronization, failure, and teardown contracts. + +**Import Harness packages from each generated wrapper.** Rejected because repository packages should not resolve or version the application's internal dependency graph. A Loader builtin supplies one app-owned implementation and keeps generated wrappers import-free. + +**Watch prepared repository assets.** Rejected because an exact repository cache generation is immutable. Ref, subdirectory, or configuration changes select a new generation; a second watcher would create an unowned refresh identity. + +**Treat MCP connect failures as Loader update failures.** Rejected because the existing MCP client deliberately contains connect failures and exposes no tools. Changing that semantic only for repository sources would create two failure contracts for the same server configuration. + +## Consequences + +- Existing skill/MCP repositories can add a small `.dsh-plugin/package.json` without relocating their assets or adopting an SDK project. +- Prepared output is deterministic static glue, while the configured repository and its dependency lifecycle remain trusted executable package-manager input rather than a sandbox. +- Multiple repository Plugins coexist through provider names and ordinary MCP server-name uniqueness; duplicate names fail through their existing registries and participate in Loader rollback. +- Cached source edits do not appear live. Another exact source/ref/path/config selection is required. +- Adding another contribution kind requires an explicit format and DSH-owned runtime consumer; it cannot arrive as repository JavaScript by accident. + +## Testing + +Focused tests prepare skills and MCP metadata, prove the emitted wrapper contains no imports, reject Work IQ-style OAuth fields, map Expo-style HTTP and DataJunction-style stdio plus environment values, and exercise missing variables. A real Loader test mounts a generated wrapper through the registered builtin, reads its skill through `ctx.skills`, removes the Loader entry, and observes provider cleanup. The keyless headless example loads a checked-in prepared wrapper through its real `cordis.yml` and snapshots the repository skill's logged model catalog row. diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md new file mode 100644 index 0000000000..0b0a2af132 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md @@ -0,0 +1,49 @@ +# Agent Note:静态 repository Plugin 格式 + +状态:已实现 + +[English](2026-07-30-static-repository-plugin-format.md) | 中文 + +## 问题 + +一个已经包含可复用 skills 或 MCP server 声明的仓库,应当能被独立 Harness 应用使用,而不必先变成 Harness SDK 项目,也不应被迫改写现有布局。常见仓库只需新增一个 `.dsh-plugin` 目录,同时仍可把原有 skills 与 `.mcp.json` 放在仓库其他位置。与此同时,如果把任意仓库入口都当作 Cordis Plugin,就会让每个仓库成为新的无限制运行时扩展表面,并绕过现有的 skill 与 MCP 生命周期所有者。 + +[Package-manager-native repository cache](2026-07-30-package-manager-native-repository-cache.md) 会准备一个精确 package source,但有意不了解任何 DSH 格式。因此本层需要一种兼容 package manager 的创作格式、确定性的已准备产物,以及在 Loader dispose 和替换期间仍保持事务性的 Cordis 组合。 + +## 决策 + +`@deepseek-ai/dsh-repository-plugin` 负责一个受限的 `.dsh-plugin` package 格式,且只允许两类贡献:skill 根和一个通用 `.mcp.json`。Package metadata 使用 `package.json#dsh.skills` 声明相对 skill 根路径,使用 `package.json#dsh.mcpServers` 声明相对 MCP 文档路径;两者至少需要一个。路径可以离开 `.dsh-plugin` 以复用仓库内容,但必须留在包含该 `.dsh-plugin` 的目录之下;因此,一个嵌套且可选择的 Plugin 可以拥有其 package 上方相邻的子树,却不能访问无关宿主路径。 + +`.dsh-plugin` package 把 `dsh-plugin-prepare` 声明为普通 package-manager `prepare` 脚本。Helper 会校验 metadata 与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。`.mjs` 扩展名避免强迫仓库作者在 package metadata 中设置 `type: module`。生成模块来自固定、无 import 的模板,只包含规范化 manifest、`inject = ['loader']`,以及对 `dsh-repository-plugin` Loader builtin 的委托。准备阶段永远不会发现、转译、打包或保留自定义仓库入口。 + +加载 DSH package 会以 effect 方式注册该 builtin。生成的包装模块使用 `import.meta.url` 把 builtin 挂载为自己的子级,因此所有贡献都归属于包装 fiber,并在 Loader 移除或回滚时消失。Builtin 会在读取资源前重新校验已准备 manifest 与路径包含关系。它只组合现有实现,而不自行注册 skills 或 MCP 工具。 + +每份已准备 skill 集合都会挂载 `dsh-skill-local`,使用唯一的 `repository:` 提供方名称、仅包含复制后的自定义根,并禁用监视。因此 `dsh-skill-local` 新增两个通用配置字段:`providerName` 和 `includeDefaultRoots`。默认值保持原有单一本地提供方行为;repository 实例设置不同名称并排除项目/用户根,使多个实例既不冲突,也不会重复宿主本地发现。 + +`.mcp.json` 中的每个 server 都变成一个现有 `dsh-mcp-client` 子级。适配层接受通用根对象 `{ "mcpServers": ... }`;stdio 定义只允许可选的 `type: "stdio"`、`command`、`args` 与 `env`,HTTP 定义只允许 `type: "http"`、`url` 与 `headers`。严格的 `${NAME}` 进程环境变量引用在运行时、cache 准备之后展开;缺失变量会使 Plugin 加载失败。HTTP 映射到 client 的 Streamable HTTP transport,stdio 使用已准备 package 目录作为 `cwd`。只有现有 client 负责连接尝试、失败日志、远端工具同步、工具调用和断开。因此 MCP 连接失败会继续沿用“Plugin 成功但不注册工具”的既有行为,不会被重新分类为 repository 准备或 Loader 失败。 + +未知 MCP 字段会被拒绝。这里有意排除 OAuth、`auth` 对象、`CLAUDE_PLUGIN_ROOT` 和更广泛的 Claude 兼容契约。Hooks、commands、agents、apps、任意 Cordis 代码、marketplace 和发现同样不受支持。Repository 子目录选择与 GitHub 配置属于后续 app/cache 集成,而不是本格式 package。 + +## 考虑过的替代方案 + +**加载仓库自己的 Cordis 入口。** 拒绝,因为这会把宣传为静态的格式变成无限制代码加载 API,要求仓库作者依赖 Harness 内部实现,并重复普通 SDK/Plugin dependency 路径。 + +**让生成包装模块直接实现 skills 和 MCP。** 拒绝,因为复制的运行时代码会与 `dsh-skill-local` 和 `dsh-mcp-client` 漂移,尤其是提供方失效、工具同步、失败和 teardown 契约。 + +**让每个生成包装模块 import Harness package。** 拒绝,因为 repository package 不应解析或锁定应用的内部依赖图。Loader builtin 提供一份由 app 所有的实现,并让生成包装模块保持无 import。 + +**监视已准备 repository 资源。** 拒绝,因为一个精确 repository cache generation 是不可变的。Ref、子目录或配置变化会选择新 generation;第二套 watcher 会创造一套没有所有者的刷新身份。 + +**把 MCP 连接失败当作 Loader 更新失败。** 拒绝,因为现有 MCP client 有意收束连接失败并不暴露工具。只对 repository source 改变该语义,会让同一 server 配置拥有两套失败契约。 + +## 后果 + +- 现有 skill/MCP 仓库可以新增一个很小的 `.dsh-plugin/package.json`,无需移动资源或采用 SDK 项目。 +- 已准备输出是确定性的静态胶水;已配置仓库及其依赖生命周期仍是受信任的可执行 package-manager 输入,而非 sandbox。 +- 多个 repository Plugin 通过提供方名称和普通 MCP server-name 唯一性共存;重复名称经现有 registry 失败,并参与 Loader 回滚。 +- Cache 内的源码编辑不会实时出现;必须选择另一个精确 source/ref/path/config。 +- 新增贡献类型必须提供显式格式和 DSH 自有运行时消费方;它不能意外以 repository JavaScript 形式进入。 + +## 测试 + +聚焦测试会准备 skills 与 MCP metadata,证明生成包装模块不含 import,拒绝 Work IQ 风格的 OAuth 字段,映射 Expo 风格 HTTP 与 DataJunction 风格 stdio 及环境变量,并覆盖缺失变量。真实 Loader 测试通过已注册 builtin 挂载生成包装模块,经 `ctx.skills` 读取其 skill,移除 Loader 条目并观察提供方清理。Keyless headless 示例通过真实 `cordis.yml` 加载一份签入的已准备包装模块,并快照 repository skill 写入日志的模型目录行。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d339d216e1..457f1be7b8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1312,6 +1312,10 @@ Requires: `skills` ```ts config-catalog /** Local filesystem skill provider configuration. */ export interface Config { + /** Unique provider name. Defaults to `local`. */ + providerName?: string + /** Whether project and user roots are included around custom roots. */ + includeDefaultRoots?: boolean /** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string /** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */ @@ -2318,6 +2322,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) +- `@deepseek-ai/dsh-repository-plugin` — requires `loader` ([`packages/cordis/repository-plugin/src/index.ts`](../packages/cordis/repository-plugin/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index f0f58a18c1..9c3afcec85 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -94,6 +94,7 @@ flowchart TD pkg_plan_mode["plan-mode"] end subgraph group_cordis["packages/cordis"] + pkg_repository_plugin["repository-plugin"] pkg_tool_cordis["tool-cordis"] end subgraph group_hooks["packages/hooks"] @@ -904,6 +905,9 @@ flowchart TD pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_tasks pkg_tool_subagent --> pkg_tools + pkg_repository_plugin --> pkg_invariants + pkg_repository_plugin --> pkg_mcp_client + pkg_repository_plugin --> pkg_skill_local pkg_hooks_claude --> pkg_agent pkg_hooks_claude --> pkg_hook_protocol pkg_hooks_claude --> pkg_invariants @@ -1208,6 +1212,7 @@ flowchart TD | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/examples/headless-agent/tests/fixtures/cli.cordis.yml b/examples/headless-agent/tests/fixtures/cli.cordis.yml index 91941c108a..5e1a296de5 100644 --- a/examples/headless-agent/tests/fixtures/cli.cordis.yml +++ b/examples/headless-agent/tests/fixtures/cli.cordis.yml @@ -1,6 +1,12 @@ - id: cli-mock-llm name: './cli-mock-llm.ts' +- id: repository-plugin-runtime + name: '@deepseek-ai/dsh-repository-plugin' + +- id: repository-plugin-fixture + name: './repository-plugin/dsh-plugin.mjs' + - id: base name: '@cordisjs/plugin-include' config: @@ -16,4 +22,8 @@ model: cli-mock persistenceRoot: './.sessions' workspaceContext: false + dshHome: './.dsh-home' + skills: + local: + agentsHome: './.agents-home' persona: 'Keyless headless-agent smoke.' diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md new file mode 100644 index 0000000000..e24104e79f --- /dev/null +++ b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md @@ -0,0 +1,6 @@ +--- +name: repository-fixture +description: Repository fixture skill. +--- + +Static instructions from a prepared repository plugin. diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs new file mode 100644 index 0000000000..31225c0afc --- /dev/null +++ b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs @@ -0,0 +1,9 @@ +// Generated by dsh-plugin-prepare. Do not edit. +const manifest = { "name": "headless-repository-fixture", "skills": ["dsh-plugin-assets/skills/0"] } +export const name = 'headless-repository-fixture' +export const inject = ['loader'] +export async function apply(ctx) { + const runtime = ctx.loader.builtins['dsh-repository-plugin'] + if (runtime === undefined) throw new Error('missing Cordis builtin dsh-repository-plugin') + await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest }) +} diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index 4cd06aed78..d5e1cce826 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -36,6 +36,17 @@ describe('headless-agent keyless smoke', () => { const result = lines.at(-1) 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') + const catalog = catalogMessage?.type === 'user/message' + ? catalogMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('\n') + : '' + expect(catalog.split('\n').find(line => line.includes('repository-fixture'))).toMatchInlineSnapshot( + ` + "- \`repository-fixture\`: Repository fixture skill." + `, + ) const toolResult = events.find(event => event.type === 'tool/result') expect(JSON.stringify(toolResult)).toContain('CLI_TOOL_ROUND_TRIP') expect(result).toMatchObject({ diff --git a/examples/package.json b/examples/package.json index b7e7b5d736..97e7918361 100644 --- a/examples/package.json +++ b/examples/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", + "@deepseek-ai/dsh-repository-plugin": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:*", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 369277ba3f..5491ce5432 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: 0c729f781151fcc0bda81899e51227e71c7b8d2b -README.zh.md: 660a24eeea5f1a36841654626d94412371a2f462 +README.md: c8984bfa652a0ad7e12bc1f2001618df452bc863 +README.zh.md: 2a59a63d22cdb2e5c0de53cd1dcfce1296882c01 diff --git a/packages/README.md b/packages/README.md index 0c729f7811..c8984bfa65 100644 --- a/packages/README.md +++ b/packages/README.md @@ -33,7 +33,7 @@ Packages live at `packages///`; groups are containers, while names r | [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface | | [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface | -| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | +| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection/model-written temporary Plugins and restricted repository Plugin loading | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface | | [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 660a24eeea..2a59a63d22 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -33,7 +33,7 @@ | [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 | | [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 | | [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 | -| [`cordis/`](cordis/README.md) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定表面 | +| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检/模型编写的临时 Plugin,以及受限 repository Plugin 加载 | 产品:稳定表面 | | [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 | | [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 | | [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 | diff --git a/packages/cordis/README.i18n.yaml b/packages/cordis/README.i18n.yaml index aaa96435d3..29f9e303de 100644 --- a/packages/cordis/README.i18n.yaml +++ b/packages/cordis/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/cordis/README.md -README.md: a47b9ba20789bb6b9a36b1af9b3942b90e61b365 -README.zh.md: 3ee1ddb1db28352cd05b3e79e88228035bc39ac4 +README.md: 485a6ce7858a77507c07b76138127faa411b354b +README.zh.md: 38bfcd9fcb50f608e83bafa34def5561a847c066 diff --git a/packages/cordis/README.md b/packages/cordis/README.md index a47b9ba207..485a6ce785 100644 --- a/packages/cordis/README.md +++ b/packages/cordis/README.md @@ -1,9 +1,10 @@ -# packages/cordis — the self-referential runtime toolset +# packages/cordis — Cordis runtime integration English | [中文](README.zh.md) -Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +Plugins that integrate Harness-owned formats with the Cordis runtime: the self-referential model toolset and the restricted repository Plugin runtime. | Package | Role | ctx key | |---|---|---| | [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` | +| [`repository-plugin/`](repository-plugin/README.md) | Prepare and mount static repository skills plus common `.mcp.json` servers through DSH-owned child Plugins | registers a Loader builtin | diff --git a/packages/cordis/README.zh.md b/packages/cordis/README.zh.md index 3ee1ddb1db..38bfcd9fcb 100644 --- a/packages/cordis/README.zh.md +++ b/packages/cordis/README.zh.md @@ -1,9 +1,10 @@ -# packages/cordis:自指运行时工具集 +# packages/cordis:Cordis 运行时集成 [English](README.md) | 中文 -这些面向模型的工具作用于 agent(智能体)自身所在的实时 Cordis 运行时,可检查已加载的插件和服务接口、挂载模型编写的插件,并将其 dispose(资源释放)。设计说明见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +这些 Plugin 把 Harness 自有格式集成到 Cordis 运行时:包括自指的模型工具集,以及受限的 repository Plugin 运行时。 | 包(package) | 角色 | ctx 键 | |---|---|---| | [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_mount`/`cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的临时插件 | 注册到 `ctx.tools` | +| [`repository-plugin/`](repository-plugin/README.md) | 通过 DSH 自有子 Plugin 准备并挂载静态 repository skills 与通用 `.mcp.json` server | 注册一个 Loader builtin | diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml new file mode 100644 index 0000000000..806ee1c3af --- /dev/null +++ b/packages/cordis/repository-plugin/README.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 packages/cordis/repository-plugin/README.md +README.md: dab6287304f083e5c0ae128d5a3cb861332c076a +README.zh.md: 790e601ad022ffa20c7d02a88353e972bf8bffe2 diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md new file mode 100644 index 0000000000..dab6287304 --- /dev/null +++ b/packages/cordis/repository-plugin/README.md @@ -0,0 +1,85 @@ +# @deepseek-ai/dsh-repository-plugin + +English | [中文](README.zh.md) + +Restricted repository Plugin format for DeepSeek Harness. A repository author declares static skill roots and an optional common `.mcp.json` in `.dsh-plugin/package.json`; the prepare helper copies those assets and emits a fixed import-free Cordis wrapper. The runtime wrapper can only delegate to this DSH-owned package, which composes [`dsh-skill-local`](../../skill/skill-local/README.md) and [`dsh-mcp-client`](../../mcp/mcp-client/README.md). Design rationale: [static repository Plugin format Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md). + +## Authoring format + +Place an ordinary package in the repository's `.dsh-plugin` directory: + +```json +{ + "name": "humanize-dsh-plugin", + "version": "0.0.0", + "private": true, + "scripts": { + "prepare": "dsh-plugin-prepare" + }, + "devDependencies": { + "@deepseek-ai/dsh-repository-plugin": "^0.0.1" + }, + "dsh": { + "skills": ["../skills"], + "mcpServers": "../.mcp.json" + } +} +``` + +`dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory. + +## Preparation + +`dsh-plugin-prepare` validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point. + +The containing package manager still runs the configured repository package's lifecycle scripts. This restriction defines the supported DSH contribution surface; it is not a security boundary for a repository that the user chose to install as executable package-manager source. + +## Runtime composition + +Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates to that builtin with its own module URL and prepared manifest. Repository skill roots mount as a uniquely named `dsh-skill-local` provider with default project/user roots excluded and watching disabled; cached package generations are immutable. Wrapper disposal removes the provider and all composed MCP clients through normal Cordis child-fiber teardown. + +## Common MCP format + +The `.mcp.json` root is `{ "mcpServers": { ... } }`. A stdio entry accepts only `type: "stdio"` (optional), `command`, `args`, and `env`; an HTTP entry accepts only `type: "http"`, `url`, and `headers`. String values support exact `${NAME}` process-environment expansion at Plugin load, and a missing name fails that load. HTTP URLs become the existing MCP client's `streamable-http` transport; stdio entries use the prepared package directory as `cwd`. + +Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle; a network or child-process connection failure retains that client's established log-and-no-tools behavior. + +## Export shape + +Namespace Plugin: named exports `name` / `inject` / `apply`, preparation constants, and `prepareDshPlugin`; no default export. The package also exposes the `dsh-plugin-prepare` executable and an invariant companion. + +## Model Experience + +### Repository skills + +#### What the model sees + +Indirectly through `dsh-tool-skill`: prepared, model-invocable skills join its logged catalog and selected instruction-body surface under their declared names and descriptions. The exact consumer schema is in the generated [`skill` tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill). + +#### Token effect + +Conditional and data-dependent: each visible repository skill adds one capped catalog row; loading one adds its full current instruction body and resource-base guidance to retained tool history. + +#### KV Cache effect + +A stable prepared Plugin set is prefix-stable. Adding, removing, or replacing a repository Plugin can append the consumer's replacement catalog and affect later request prefixes. + +### Repository MCP tools + +#### What the model sees + +Indirectly through `dsh-mcp-client`: every connected server contributes its server-qualified tool schemas, and calls retain that client's canonical MCP results and rendering. + +#### Token effect + +Conditional on successful connection and the remote tool list; schemas recur on requests in the active tool view, while calls and results remain in history until compaction. + +#### KV Cache effect + +Stable connected tool lists are prefix-stable. Plugin lifecycle or MCP tool-list changes can change later tool-schema prefixes from the first affected definition. + +## Known Limitations and Deferred Work + +- **Skills and MCP only** — commands, hooks, agents, apps, arbitrary Cordis code, marketplaces, and compatibility shims are intentionally outside this format. +- **No MCP authentication protocol** — static headers may use environment expansion, but OAuth-bearing definitions reject and private-server login flows are not implemented here. +- **Generated assets are immutable runtime input** — repository cache generations are not watched; source, ref, path, or configuration must select another prepared generation. diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md new file mode 100644 index 0000000000..790e601ad0 --- /dev/null +++ b/packages/cordis/repository-plugin/README.zh.md @@ -0,0 +1,85 @@ +# @deepseek-ai/dsh-repository-plugin + +[English](README.md) | 中文 + +这是 DeepSeek Harness 的受限 repository Plugin 格式。仓库作者在 `.dsh-plugin/package.json` 中声明静态 skill 根和可选的通用 `.mcp.json`;prepare helper 会复制这些资源并生成固定、无 import 的 Cordis 包装模块。运行时包装模块只能委托给这个由 DSH 自有的包,再由它组合 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md)。设计依据见[静态 repository Plugin 格式 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。 + +## 创作格式 + +在仓库的 `.dsh-plugin` 目录中放置一个普通 package: + +```json +{ + "name": "humanize-dsh-plugin", + "version": "0.0.0", + "private": true, + "scripts": { + "prepare": "dsh-plugin-prepare" + }, + "devDependencies": { + "@deepseek-ai/dsh-repository-plugin": "^0.0.1" + }, + "dsh": { + "skills": ["../skills"], + "mcpServers": "../.mcp.json" + } +} +``` + +`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` package。 + +## 准备阶段 + +`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。 + +外层 package manager 仍会运行已配置仓库 package 的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行 package-manager source 安装的仓库,它并不是安全边界。 + +## 运行时组合 + +加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。 + +## 通用 MCP 格式 + +`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在 Plugin 加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的 package 目录作为 `cwd`。 + +未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期;网络或子进程连接失败沿用该 client 既有的“记录错误且不注册工具”行为。 + +## 导出形状 + +Namespace Plugin:具名导出 `name`/`inject`/`apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。 + +## 模型体验 + +### Repository skills + +#### 模型看到什么 + +通过 `dsh-tool-skill` 间接呈现:已准备且允许模型调用的 skill 会按其声明的名称和描述进入该消费方记录到日志的目录及所选指令正文表面。消费方的确切 schema 见生成的 [`skill` 工具目录](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill)。 + +#### Token 影响 + +有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基准指引加入保留的工具历史。 + +#### KV Cache 影响 + +稳定的已准备 Plugin 集合保持前缀稳定。添加、移除或替换 repository Plugin 可能使消费方追加替换目录,并影响后续请求前缀。 + +### Repository MCP 工具 + +#### 模型看到什么 + +通过 `dsh-mcp-client` 间接呈现:每个已连接 server 都贡献带 server 限定名的工具 schema;调用会保留该 client 的规范 MCP 结果和渲染。 + +#### Token 影响 + +取决于连接成功和远端工具列表;schema 会在对应工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩。 + +#### KV Cache 影响 + +稳定的已连接工具列表保持前缀稳定。Plugin 生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。 + +## 已知限制与延后工作 + +- **仅支持 skills 与 MCP**:commands、hooks、agents、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。 +- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。 +- **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。 diff --git a/packages/cordis/repository-plugin/package.json b/packages/cordis/repository-plugin/package.json new file mode 100644 index 0000000000..f6500eb007 --- /dev/null +++ b/packages/cordis/repository-plugin/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-repository-plugin", + "description": "Restricted repository plugin format and Cordis runtime for DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "bin": { + "dsh-plugin-prepare": "./lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/bin.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-mcp-client": "^0.0.1", + "@deepseek-ai/dsh-skill-local": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/cordis/repository-plugin/src/bin.ts b/packages/cordis/repository-plugin/src/bin.ts new file mode 100644 index 0000000000..a1787ff090 --- /dev/null +++ b/packages/cordis/repository-plugin/src/bin.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +/** Command-line entry that prepares the current `.dsh-plugin` package. @module */ + +import { prepareDshPlugin } from './format.ts' + +try { + await prepareDshPlugin() +} catch (error) { + process.stderr.write(`dsh-plugin-prepare: ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 +} diff --git a/packages/cordis/repository-plugin/src/format.ts b/packages/cordis/repository-plugin/src/format.ts new file mode 100644 index 0000000000..0c00142383 --- /dev/null +++ b/packages/cordis/repository-plugin/src/format.ts @@ -0,0 +1,168 @@ +/** + * Static repository-plugin preparation and prepared-manifest validation. + * @module + */ + +import { cp, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { z } from 'zod' +import { parseMcpDocument } from './mcp.ts' + +/** Fixed module filename loaded from an installed prepared plugin package. */ +export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs' +/** Fixed directory containing copied static plugin assets. */ +export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets' +/** Loader builtin used by every generated import-free wrapper. */ +export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin' + +const sourceMetadataSchema = z.object({ + skills: z.array(z.string().min(1)).default([]), + mcpServers: z.string().min(1).optional(), +}).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined, { + message: 'declare at least one skill root or mcpServers file', +}) +const sourcePackageSchema = z.looseObject({ + name: z.string().min(1), + dsh: sourceMetadataSchema, +}) +const preparedManifestSchema = z.object({ + name: z.string().min(1), + skills: z.array(z.string().min(1)), + mcpServers: z.string().min(1).optional(), +}).strict() +const preparedConfigSchema = z.object({ + baseUrl: z.url(), + manifest: preparedManifestSchema, +}).strict() + +/** Static manifest embedded in the generated wrapper. */ +export interface PreparedPluginManifest { + name: string + skills: string[] + mcpServers?: string +} + +/** Untrusted generated-wrapper config accepted by the DSH-owned runtime builtin. */ +export interface PreparedPluginConfig { + baseUrl: string + manifest: PreparedPluginManifest +} + +function formatZodError(label: string, error: z.ZodError): Error { + return new Error(`${label}:\n${z.prettifyError(error)}`) +} + +/** + * Validate the config passed by an installed prepared wrapper. + * @param value - wrapper-provided value crossing the file/module boundary. + * @returns a detached typed config. + */ +export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig { + const result = preparedConfigSchema.safeParse(value) + if (!result.success) throw formatZodError('invalid prepared DSH plugin', result.error) + return { + baseUrl: result.data.baseUrl, + manifest: { + name: result.data.manifest.name, + skills: result.data.manifest.skills, + ...result.data.manifest.mcpServers === undefined ? {} : { mcpServers: result.data.manifest.mcpServers }, + }, + } +} + +function isOutside(root: string, candidate: string): boolean { + const path = relative(root, candidate) + /* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */ + return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) +} + +async function sourcePath(pluginDirectory: string, sourceRoot: string, configured: string, kind: 'directory' | 'file'): Promise { + if (isAbsolute(configured)) throw new Error(`DSH plugin asset path must be relative: ${JSON.stringify(configured)}`) + let path: string + try { + path = await realpath(resolve(pluginDirectory, configured)) + } catch (cause) { + throw new Error(`DSH plugin asset does not exist: ${JSON.stringify(configured)}`, { cause }) + } + if (isOutside(sourceRoot, path)) { + throw new Error(`DSH plugin asset escapes its plugin source root: ${JSON.stringify(configured)}`) + } + const info = await stat(path) + if (kind === 'directory' ? !info.isDirectory() : !info.isFile()) { + throw new Error(`DSH plugin asset is not a ${kind}: ${JSON.stringify(configured)}`) + } + return path +} + +function wrapperSource(manifest: PreparedPluginManifest): string { + return [ + '// Generated by dsh-plugin-prepare. Do not edit.', + `const manifest = ${JSON.stringify(manifest)}`, + `export const name = ${JSON.stringify(manifest.name)}`, + "export const inject = ['loader']", + 'export async function apply(ctx) {', + ` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`, + ` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`, + ' await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })', + '}', + '', + ].join('\n') +} + +/** + * Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper. + * @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd. + * @returns the generated static manifest. + */ +export async function prepareDshPlugin(directory: string = process.cwd()): Promise { + const pluginDirectory = await realpath(resolve(directory)) + let packageValue: unknown + try { + packageValue = JSON.parse(await readFile(join(pluginDirectory, 'package.json'), 'utf8')) as unknown + } catch (cause) { + throw new Error(`failed to read DSH plugin package metadata in ${pluginDirectory}`, { cause }) + } + const parsed = sourcePackageSchema.safeParse(packageValue) + if (!parsed.success) throw formatZodError('invalid package.json#dsh', parsed.error) + + const sourceRoot = await realpath(dirname(pluginDirectory)) + const skillSources: string[] = [] + for (const configured of parsed.data.dsh.skills) { + const source = await sourcePath(pluginDirectory, sourceRoot, configured, 'directory') + if (!isOutside(source, pluginDirectory)) { + throw new Error(`DSH skill root cannot contain the .dsh-plugin package: ${JSON.stringify(configured)}`) + } + skillSources.push(source) + } + let mcpSource: string | undefined + if (parsed.data.dsh.mcpServers !== undefined) { + mcpSource = await sourcePath(pluginDirectory, sourceRoot, parsed.data.dsh.mcpServers, 'file') + parseMcpDocument(await readFile(mcpSource, 'utf8')) + } + + const manifest: PreparedPluginManifest = { + name: parsed.data.name, + skills: skillSources.map((_, index) => `${PREPARED_ASSET_DIRECTORY}/skills/${index}`), + ...mcpSource === undefined ? {} : { mcpServers: `${PREPARED_ASSET_DIRECTORY}/.mcp.json` }, + } + const staging = await mkdtemp(join(pluginDirectory, '.dsh-plugin-prepare-')) + try { + const stagedAssets = join(staging, PREPARED_ASSET_DIRECTORY) + await mkdir(join(stagedAssets, 'skills'), { recursive: true }) + await Promise.all(skillSources.map((source, index) => cp(source, join(stagedAssets, 'skills', String(index)), { + recursive: true, + force: false, + errorOnExist: true, + }))) + if (mcpSource !== undefined) await copyFile(mcpSource, join(stagedAssets, '.mcp.json')) + await writeFile(join(staging, PREPARED_ENTRY_FILENAME), wrapperSource(manifest)) + + await rm(join(pluginDirectory, PREPARED_ASSET_DIRECTORY), { recursive: true, force: true }) + await rm(join(pluginDirectory, PREPARED_ENTRY_FILENAME), { force: true }) + await rename(stagedAssets, join(pluginDirectory, PREPARED_ASSET_DIRECTORY)) + await rename(join(staging, PREPARED_ENTRY_FILENAME), join(pluginDirectory, PREPARED_ENTRY_FILENAME)) + } finally { + await rm(staging, { recursive: true, force: true }) + } + return manifest +} diff --git a/packages/cordis/repository-plugin/src/index.ts b/packages/cordis/repository-plugin/src/index.ts new file mode 100644 index 0000000000..50a76fc952 --- /dev/null +++ b/packages/cordis/repository-plugin/src/index.ts @@ -0,0 +1,97 @@ +/** + * Restricted repository-plugin runtime for static skills and common MCP definitions. + * @module @deepseek-ai/dsh-repository-plugin + */ + +import { readFile } from 'node:fs/promises' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from 'cordis' +import type {} from '@cordisjs/plugin-loader' +import * as SkillLocal from '@deepseek-ai/dsh-skill-local' +import * as McpClient from '@deepseek-ai/dsh-mcp-client' +import { + REPOSITORY_PLUGIN_BUILTIN, + parsePreparedPluginConfig, + type PreparedPluginConfig, +} from './format.ts' +import { parseMcpDocument, resolveMcpServers } from './mcp.ts' + +export { + PREPARED_ASSET_DIRECTORY, + PREPARED_ENTRY_FILENAME, + REPOSITORY_PLUGIN_BUILTIN, + prepareDshPlugin, + type PreparedPluginManifest, +} from './format.ts' + +/** Cordis plugin name used by Loader diagnostics. */ +export const name = 'repository-plugin' +/** Loader service required to register the fixed prepared-wrapper builtin. */ +export const inject = ['loader'] + +function preparedPath(baseUrl: string, configured: string): string { + if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`) + const directory = dirname(fileURLToPath(baseUrl)) + const path = resolve(directory, configured) + const rel = relative(directory, path) + /* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */ + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error(`prepared DSH plugin path escapes its package: ${JSON.stringify(configured)}`) + } + return path +} + +async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise { + const config = parsePreparedPluginConfig(value) + const directory = dirname(fileURLToPath(config.baseUrl)) + const skillDirectories = config.manifest.skills.map(path => preparedPath(config.baseUrl, path)) + const mcpConfigs = config.manifest.mcpServers === undefined + ? [] + : resolveMcpServers( + parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')), + process.env, + directory, + ).map(input => McpClient.Config(input as never)) + + await ctx.effect(async function* () { + if (skillDirectories.length > 0) { + const skills = ctx.plugin(SkillLocal, { + providerName: `repository:${config.manifest.name}`, + includeDefaultRoots: false, + customSkillDirs: skillDirectories, + watch: false, + }) + await skills + yield skills.dispose + } + for (const mcpConfig of mcpConfigs) { + const mcp = ctx.plugin(McpClient, mcpConfig) + await mcp + yield mcp.dispose + } + }, `repository-plugin(${config.manifest.name})`) +} + +const preparedRuntime = { + name: 'repository-plugin-runtime', + apply: applyPrepared, +} + +/** + * Register the DSH-owned runtime as the Loader builtin used by fixed prepared wrappers. + * @param ctx - plugin context carrying the Loader service. + */ +export function apply(ctx: Context): void { + if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) { + throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`) + } + ctx.effect(function* () { + ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime + yield () => { + if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) { + Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN) + } + } + }, 'repository-plugin Loader builtin') +} diff --git a/packages/cordis/repository-plugin/src/invariant.ts b/packages/cordis/repository-plugin/src/invariant.ts new file mode 100644 index 0000000000..410e8bf69e --- /dev/null +++ b/packages/cordis/repository-plugin/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-repository-plugin`. + * @module @deepseek-ai/dsh-repository-plugin/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin' + +/** Cordis companion plugin name. */ +export const name = 'repository-plugin-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the package owns no service state; Loader fibers and the existing skill + * and MCP owners expose the authoritative lifecycle relationships for its composed children. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/cordis/repository-plugin/src/mcp.ts b/packages/cordis/repository-plugin/src/mcp.ts new file mode 100644 index 0000000000..893d96f086 --- /dev/null +++ b/packages/cordis/repository-plugin/src/mcp.ts @@ -0,0 +1,145 @@ +/** + * Parser for the common `.mcp.json` file consumed by prepared repository plugins. + * @module + */ + +import { z } from 'zod' + +const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ +const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ +const PLACEHOLDER_PATTERN = /\$\{([^}]*)\}/g + +const stringMap = z.record(z.string(), z.string()) +const stdioServerSchema = z.object({ + type: z.literal('stdio').optional(), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: stringMap.optional(), +}).strict() +const httpServerSchema = z.object({ + type: z.literal('http'), + url: z.string().min(1), + headers: stringMap.optional(), +}).strict() +const documentSchema = z.object({ + mcpServers: z.record(z.string(), z.union([stdioServerSchema, httpServerSchema])), +}).strict() + +/** One supported server entry from the common `.mcp.json` format. */ +export type McpServerDefinition = z.infer | z.infer + +/** Parsed common MCP document before process-environment expansion. */ +export interface McpDocument { + mcpServers: Record +} + +/** Resolved input handed to the existing `dsh-mcp-client` Config schema. */ +export type ResolvedMcpServer = + | { + transport: 'stdio' + serverName: string + command: string + args: string[] + env: Record + cwd: string + } + | { + transport: 'streamable-http' + serverName: string + url: string + headers: Record + } + +function assertTemplate(value: string, location: string): void { + for (const match of value.matchAll(PLACEHOLDER_PATTERN)) { + const name = match[1] as string + if (!ENVIRONMENT_NAME_PATTERN.test(name)) { + throw new Error(`${location} contains an unsupported environment placeholder ${JSON.stringify(match[0])}`) + } + } + if (value.replace(PLACEHOLDER_PATTERN, '').includes('${')) { + throw new Error(`${location} contains an unterminated environment placeholder`) + } +} + +function visitStrings(serverName: string, definition: McpServerDefinition, visit: (value: string, location: string) => void): void { + if ('command' in definition) { + visit(definition.command, `mcpServers.${serverName}.command`) + definition.args?.forEach((value, index) => { visit(value, `mcpServers.${serverName}.args[${index}]`) }) + Object.entries(definition.env ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.env.${name}`) }) + return + } + visit(definition.url, `mcpServers.${serverName}.url`) + Object.entries(definition.headers ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.headers.${name}`) }) +} + +/** + * Parse and validate one common `.mcp.json` document without resolving environment values. + * @param content - UTF-8 JSON document. + * @returns the supported stdio and Streamable HTTP server definitions. + */ +export function parseMcpDocument(content: string): McpDocument { + let value: unknown + try { + value = JSON.parse(content) as unknown + } catch (cause) { + throw new Error('invalid .mcp.json: expected JSON', { cause }) + } + const result = documentSchema.safeParse(value) + if (!result.success) throw new Error(`invalid .mcp.json:\n${z.prettifyError(result.error)}`) + for (const [serverName, definition] of Object.entries(result.data.mcpServers)) { + if (!SERVER_NAME_PATTERN.test(serverName)) { + throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match [A-Za-z0-9_-]{1,32}`) + } + visitStrings(serverName, definition, assertTemplate) + } + return result.data +} + +function expand(value: string, environment: NodeJS.ProcessEnv, location: string): string { + return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => { + const replacement = environment[name] + if (replacement === undefined) throw new Error(`${location} requires missing environment variable ${name}`) + return replacement + }) +} + +function expandMap(values: Record | undefined, environment: NodeJS.ProcessEnv, location: string): Record { + return Object.fromEntries(Object.entries(values ?? {}).map(([name, value]) => [ + name, + expand(value, environment, `${location}.${name}`), + ])) +} + +/** + * Resolve supported MCP definitions to inputs for the existing MCP client. + * @param document - validated common MCP document. + * @param environment - process environment used for exact `${NAME}` expansion. + * @param cwd - prepared plugin directory used for stdio child processes. + * @returns one existing-client config input per declared server. + */ +export function resolveMcpServers(document: McpDocument, environment: NodeJS.ProcessEnv, cwd: string): ResolvedMcpServer[] { + return Object.entries(document.mcpServers).map(([serverName, definition]) => { + if ('command' in definition) { + return { + transport: 'stdio', + serverName, + command: expand(definition.command, environment, `mcpServers.${serverName}.command`), + args: (definition.args ?? []).map((value, index) => expand(value, environment, `mcpServers.${serverName}.args[${index}]`)), + env: expandMap(definition.env, environment, `mcpServers.${serverName}.env`), + cwd, + } + } + const url = expand(definition.url, environment, `mcpServers.${serverName}.url`) + const protocol = new URL(url).protocol + if (protocol !== 'http:' && protocol !== 'https:') { + throw new Error(`mcpServers.${serverName}.url must use http or https`) + } + return { + transport: 'streamable-http', + serverName, + url, + headers: expandMap(definition.headers, environment, `mcpServers.${serverName}.headers`), + } + }) +} diff --git a/packages/cordis/repository-plugin/tests/mcp-format.spec.ts b/packages/cordis/repository-plugin/tests/mcp-format.spec.ts new file mode 100644 index 0000000000..094cb93020 --- /dev/null +++ b/packages/cordis/repository-plugin/tests/mcp-format.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { parseMcpDocument, resolveMcpServers } from '../src/mcp.ts' + +describe('repository plugin common .mcp.json support', () => { + it('maps Expo-style HTTP servers to the existing Streamable HTTP client config', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { + expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' }, + }, + })) + + expect(resolveMcpServers(document, {}, '/plugin')).toEqual([{ + transport: 'streamable-http', + serverName: 'expo', + url: 'https://mcp.expo.dev/mcp', + headers: {}, + }]) + }) + + it('maps DataJunction-style stdio servers and expands exact environment placeholders', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { + datajunction: { + command: 'dj-mcp', + args: ['--endpoint', '${DJ_API_URL}'], + env: { DJ_API_URL: '${DJ_API_URL}' }, + }, + }, + })) + + expect(resolveMcpServers(document, { DJ_API_URL: 'http://localhost:8000' }, '/plugin')).toEqual([{ + transport: 'stdio', + serverName: 'datajunction', + command: 'dj-mcp', + args: ['--endpoint', 'http://localhost:8000'], + env: { DJ_API_URL: 'http://localhost:8000' }, + cwd: '/plugin', + }]) + }) + + it('fails loud when a declared environment value is absent', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { datajunction: { command: 'dj-mcp', env: { DJ_API_URL: '${DJ_API_URL}' } } }, + })) + + expect(() => resolveMcpServers(document, {}, '/plugin')).toThrow('missing environment variable DJ_API_URL') + }) + + it('accepts explicit stdio defaults and expands HTTP URLs and headers', () => { + const document = parseMcpDocument(JSON.stringify({ + mcpServers: { + local: { type: 'stdio', command: 'local-mcp' }, + remote: { + type: 'http', + url: 'http://${MCP_HOST}/mcp', + headers: { Authorization: 'Bearer ${MCP_TOKEN}' }, + }, + }, + })) + + expect(resolveMcpServers(document, { MCP_HOST: 'localhost:3000', MCP_TOKEN: 'test-token' }, '/plugin')).toEqual([ + { + transport: 'stdio', + serverName: 'local', + command: 'local-mcp', + args: [], + env: {}, + cwd: '/plugin', + }, + { + transport: 'streamable-http', + serverName: 'remote', + url: 'http://localhost:3000/mcp', + headers: { Authorization: 'Bearer test-token' }, + }, + ]) + }) + + it('rejects malformed JSON, server names, placeholders, and non-HTTP URLs', () => { + expect(() => parseMcpDocument('{')).toThrow('expected JSON') + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { 'bad name': { command: 'server' } }, + }))).toThrow('server name') + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { bad: { command: '${BAD-NAME}' } }, + }))).toThrow('unsupported environment placeholder') + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { bad: { command: '${UNFINISHED' } }, + }))).toThrow('unterminated environment placeholder') + const ftp = parseMcpDocument(JSON.stringify({ + mcpServers: { remote: { type: 'http', url: 'ftp://example.test/mcp' } }, + })) + expect(() => resolveMcpServers(ftp, {}, '/plugin')).toThrow('must use http or https') + }) + + it('rejects Work IQ OAuth fields instead of treating them as unauthenticated HTTP', () => { + expect(() => parseMcpDocument(JSON.stringify({ + mcpServers: { + workiq: { + type: 'http', + url: 'https://workiq.microsoft.com/mcp', + oauthClientId: 'client-id', + oauthPublicClient: true, + auth: { redirectPort: 3317 }, + }, + }, + }))).toThrow('invalid .mcp.json') + }) +}) diff --git a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts new file mode 100644 index 0000000000..f8e5807501 --- /dev/null +++ b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts @@ -0,0 +1,243 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import SkillService from '@deepseek-ai/dsh-skill' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin' +import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant' +import { parsePreparedPluginConfig } from '../src/format.ts' + +const roots: string[] = [] + +async function temporaryDirectory(name: string): Promise { + const directory = await mkdtemp(join(tmpdir(), `dsh-repository-plugin-${name}-`)) + roots.push(directory) + return directory +} + +async function writePlugin(root: string, name: string, dsh: Record): Promise { + const directory = join(root, '.dsh-plugin') + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'package.json'), `${JSON.stringify({ name, version: '0.0.0', dsh }, undefined, 2)}\n`) + return directory +} + +async function writeSkill(root: string, name: string): Promise { + const directory = join(root, name) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Repository fixture skill.\n---\n\nStatic instructions.\n`) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('dsh-plugin-prepare', () => { + it('copies declared static assets and emits the fixed import-free wrapper', async () => { + const root = await temporaryDirectory('prepare') + await writeSkill(join(root, 'skills'), 'repository-fixture') + await writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { + expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' }, + }, + })) + const directory = await writePlugin(root, 'fixture-plugin', { + skills: ['../skills'], + mcpServers: '../.mcp.json', + }) + + await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({ + name: 'fixture-plugin', + skills: ['dsh-plugin-assets/skills/0'], + mcpServers: 'dsh-plugin-assets/.mcp.json', + }) + const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8') + expect(wrapper).toContain(`ctx.loader.builtins["${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}"]`) + expect(wrapper).not.toMatch(/\b(?:import|from)\s/) + await expect(readFile(join(directory, 'dsh-plugin-assets/skills/0/repository-fixture/SKILL.md'), 'utf8')) + .resolves.toContain('Static instructions.') + await expect(readFile(join(directory, 'dsh-plugin-assets/.mcp.json'), 'utf8')) + .resolves.toContain('mcp.expo.dev') + }) + + it('rejects unsupported OAuth MCP metadata before publishing outputs', async () => { + const root = await temporaryDirectory('oauth') + await writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { + workiq: { + type: 'http', + url: 'https://workiq.microsoft.com/mcp', + oauthClientId: 'client-id', + oauthPublicClient: true, + auth: { redirectPort: 3317 }, + }, + }, + })) + const directory = await writePlugin(root, 'unsupported-oauth', { mcpServers: '../.mcp.json' }) + + await expect(RepositoryPlugin.prepareDshPlugin(directory)).rejects.toThrow('invalid .mcp.json') + await expect(readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rejects invalid metadata, missing assets, wrong asset types, and escaped paths', async () => { + const malformedRoot = await temporaryDirectory('malformed-package') + const malformed = join(malformedRoot, '.dsh-plugin') + await mkdir(malformed) + await writeFile(join(malformed, 'package.json'), '{') + await expect(RepositoryPlugin.prepareDshPlugin(malformed)).rejects.toThrow('failed to read DSH plugin package metadata') + + const emptyRoot = await temporaryDirectory('empty-metadata') + const empty = await writePlugin(emptyRoot, 'empty', {}) + await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root or mcpServers file') + + const missingRoot = await temporaryDirectory('missing-asset') + const missing = await writePlugin(missingRoot, 'missing', { skills: ['../missing'] }) + await expect(RepositoryPlugin.prepareDshPlugin(missing)).rejects.toThrow('asset does not exist') + + const absoluteRoot = await temporaryDirectory('absolute-asset') + const absolute = await writePlugin(absoluteRoot, 'absolute', { skills: [absoluteRoot] }) + await expect(RepositoryPlugin.prepareDshPlugin(absolute)).rejects.toThrow('asset path must be relative') + + const wrongTypeRoot = await temporaryDirectory('wrong-type') + await writeFile(join(wrongTypeRoot, 'not-a-directory'), 'text') + const wrongType = await writePlugin(wrongTypeRoot, 'wrong-type', { skills: ['../not-a-directory'] }) + await expect(RepositoryPlugin.prepareDshPlugin(wrongType)).rejects.toThrow('asset is not a directory') + + const wrongMcpRoot = await temporaryDirectory('wrong-mcp-type') + await mkdir(join(wrongMcpRoot, 'not-a-file')) + const wrongMcp = await writePlugin(wrongMcpRoot, 'wrong-mcp', { mcpServers: '../not-a-file' }) + await expect(RepositoryPlugin.prepareDshPlugin(wrongMcp)).rejects.toThrow('asset is not a file') + + const containingRoot = await temporaryDirectory('containing-root') + const containing = await writePlugin(containingRoot, 'containing', { skills: ['..'] }) + await expect(RepositoryPlugin.prepareDshPlugin(containing)).rejects.toThrow('cannot contain the .dsh-plugin package') + + const escapedRoot = await temporaryDirectory('escaped-root') + const outside = await temporaryDirectory('outside-root') + await writeSkill(outside, 'outside-skill') + const escaped = await writePlugin(escapedRoot, 'escaped', { skills: [relative(join(escapedRoot, '.dsh-plugin'), outside)] }) + await expect(RepositoryPlugin.prepareDshPlugin(escaped)).rejects.toThrow('escapes its plugin source root') + }) + + it('validates prepared wrapper configs with and without MCP assets', () => { + expect(() => parsePreparedPluginConfig({})).toThrow('invalid prepared DSH plugin') + expect(parsePreparedPluginConfig({ + baseUrl: 'file:///plugin/dsh-plugin.mjs', + manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' }, + })).toEqual({ + baseUrl: 'file:///plugin/dsh-plugin.mjs', + manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' }, + }) + }) +}) + +describe('prepared repository plugin Loader composition', () => { + it('mounts and removes copied skills through the real Loader and skill-local provider', async () => { + const root = await temporaryDirectory('loader') + await writeSkill(join(root, 'skills'), 'loaded-from-repository') + const directory = await writePlugin(root, 'loader-fixture', { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(directory).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(SkillService) + const registrar = ctx.plugin(RepositoryPlugin) + await registrar + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined() + + const id = await ctx.loader.create({ + name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, + }) + await ctx.loader.await() + await expect(ctx.skills.get('loaded-from-repository')).resolves.toMatchObject({ + name: 'loaded-from-repository', + provider: 'repository:loader-fixture', + content: 'Static instructions.', + }) + + await ctx.loader.remove(id) + await expect(ctx.skills.get('loaded-from-repository')).resolves.toBeUndefined() + await registrar.dispose() + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('delegates an MCP-only plugin to the existing client without turning connect failure into Loader failure', async () => { + const root = await temporaryDirectory('mcp-loader') + await writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { offline: { command: join(root, 'missing-mcp-command') } }, + })) + const directory = await writePlugin(root, 'mcp-loader-fixture', { mcpServers: '../.mcp.json' }) + await RepositoryPlugin.prepareDshPlugin(directory) + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(directory).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RepositoryPlugin) + const id = await ctx.loader.create({ + name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, + }) + await ctx.loader.await() + expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false) + await ctx.loader.remove(id) + await ctx.fiber.dispose() + }) + + it('rejects hostile prepared paths before mounting children', async () => { + const root = await temporaryDirectory('prepared-paths') + const ctx = new Context() + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(RepositoryPlugin) + + for (const [filename, skillPath] of [ + ['absolute.mjs', resolve(root)], + ['escaped.mjs', '../outside'], + ] as const) { + const wrapper = join(root, filename) + await writeFile(wrapper, [ + "export const inject = ['loader']", + 'export async function apply(ctx) {', + ` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`, + ` baseUrl: import.meta.url, manifest: { name: 'hostile', skills: [${JSON.stringify(skillPath)}] },`, + ' })', + '}', + '', + ].join('\n')) + await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow('prepared DSH plugin path') + } + await ctx.fiber.dispose() + }) + + it('rejects duplicate builtin ownership and preserves a later replacement on teardown', async () => { + const ctx = new Context() + await ctx.plugin(Loader) + const registrar = ctx.plugin(RepositoryPlugin) + await registrar + expect(() => { RepositoryPlugin.apply(ctx) }).toThrow('already registered') + + const replacement = { name: 'replacement', apply() {} } + ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement + await registrar.dispose() + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBe(replacement) + await ctx.fiber.dispose() + }) +}) + +describe('repository plugin invariant companion', () => { + it('registers its explained empty invariant', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(RepositoryPluginInvariant).await()).resolves.toBeDefined() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/cordis/repository-plugin/tsconfig.json b/packages/cordis/repository-plugin/tsconfig.json new file mode 100644 index 0000000000..f7918dcdd9 --- /dev/null +++ b/packages/cordis/repository-plugin/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../skill/skill-local" + }, + { + "path": "../../mcp/mcp-client" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/cordis/repository-plugin/tsdown.config.ts b/packages/cordis/repository-plugin/tsdown.config.ts new file mode 100644 index 0000000000..ac8e9a5fe0 --- /dev/null +++ b/packages/cordis/repository-plugin/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsdown' + +/** Build the runtime, invariant, and prepare executable as self-contained entries. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, +]) diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index d1fa4602be..1902122c68 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/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/skill-local/README.md -README.md: 2077cf852fe90f7a0fec4e9bda1e9ff68fc56453 -README.zh.md: ba1c71f1bc1916daad82d872ae6658bb203133c9 +README.md: 836a2a631e9e6e452a11e3cffc102de355f1c5d9 +README.zh.md: 2e2cc45ad80f760e04f813b7ee85932b51b1df05 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 2077cf852f..836a2a631e 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -14,6 +14,8 @@ Requires `ctx.skills` (`inject: ['skills']`). | Field | Default | Meaning | |---|---|---| +| `providerName` | `local` | Unique name used to register this provider on `ctx.skills`. | +| `includeDefaultRoots` | `true` | Include project and user roots around `customSkillDirs`; set false for an isolated custom-root provider. | | `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md); scans `skills` under this directory. | | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. | | `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. | @@ -36,7 +38,7 @@ Default roots are resolved in this provider's rank order: | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. This provider supplies project and user skills; another provider may supply built-in system skills. +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits both project and user rows while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers such as immutable repository Plugins. This provider supplies project and user skills; another provider may supply built-in system skills. When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index ba1c71f1bc..2e2cc45ad8 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -14,6 +14,8 @@ | 字段 | 默认值 | 含义 | |---|---|---| +| `providerName` | `local` | 在 `ctx.skills` 上注册该提供方时使用的唯一名称。 | +| `includeDefaultRoots` | `true` | 在 `customSkillDirs` 周围包含项目根和用户根;设为 false 时仅使用隔离的自定义根。 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 DeepSeek Harness 配置根目录;扫描该目录下的 `skills`。 | | `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.agents` | 为兼容 skill 扫描的共享 agent(智能体)配置根目录。 | | `customSkillDirs` | `[]` | 在项目根目录之后、用户根目录之前扫描的其他本地 skill 根目录。 | @@ -36,7 +38,7 @@ | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 +项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目和用户两类根,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个唯一命名的隔离提供方,例如不可变 repository Plugin。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;格式错误或非文本条目会警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index a19fa1dde5..aa07443a56 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -47,6 +47,10 @@ export const inject = ['skills'] /** Local filesystem skill provider configuration. */ export interface Config { + /** Unique provider name. Defaults to `local`. */ + providerName?: string + /** Whether project and user roots are included around custom roots. */ + includeDefaultRoots?: boolean /** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string /** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */ @@ -70,6 +74,8 @@ export interface Config { } export const Config: Schema = z.object({ + providerName: z.string().min(1).default('local'), + includeDefaultRoots: z.boolean().default(true), dshHome: z.string(), agentsHome: z.string(), customSkillDirs: z.array(z.string()).default([]), @@ -138,7 +144,8 @@ export function apply(ctx: Context, config: Config = {}): void { /** Provider that maps local project/user skill roots into `ctx.skills`. */ export class LocalSkillProvider implements SkillProvider { - readonly name = 'local' + readonly name: string + private readonly includeDefaultRoots: boolean private readonly dshHome: string private readonly agentsHome: string private readonly customSkillDirs: string[] @@ -151,6 +158,8 @@ export class LocalSkillProvider implements SkillProvider { control: SkillProviderControl, config: Config = {}, ) { + this.name = config.providerName ?? 'local' + this.includeDefaultRoots = config.includeDefaultRoots ?? true this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) @@ -177,7 +186,7 @@ export class LocalSkillProvider implements SkillProvider { } const candidates: SkillCandidate[] = [] for (const root of roots) { - for (const skill of await discoverRoot(root, this.ctx)) { + for (const skill of await discoverRoot(root, this.ctx, this.name)) { candidates.push(skill) } } @@ -227,21 +236,23 @@ export class LocalSkillProvider implements SkillProvider { private async roots(cwd: string | undefined): Promise { const roots: SkillRoot[] = [] - if (cwd !== undefined) { + if (this.includeDefaultRoots && cwd !== undefined) { const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx)) roots.push( { path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK, projectRoot }, { path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK, projectRoot }, ) } - roots.push( - ...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })), - { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true }, - { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK }, - ...this.bundledSkillDir === undefined - ? [] - : [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }], - ) + roots.push(...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK }))) + if (this.includeDefaultRoots) { + roots.push( + { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true }, + { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK }, + ) + } + if (this.bundledSkillDir !== undefined) { + roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_RANK, trustedHost: true }) + } return roots } } @@ -693,7 +704,7 @@ function hasErrorCode(error: unknown, code: string): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === code } -async function discoverRoot(root: SkillRoot, ctx: Context): Promise { +async function discoverRoot(root: SkillRoot, ctx: Context, provider: string): Promise { const skills: SkillCandidate[] = [] const entries = await listSkillRootEntries(root, ctx) for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { @@ -711,7 +722,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise Date: Thu, 30 Jul 2026 04:54:52 +0800 Subject: [PATCH 016/114] fix(lockfile): link repository plugin to vendored Cordis --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3c6871303..4eb0aedbfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2382,7 +2382,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: link:../../../vendor/cordis packages/cordis/tool-cordis: dependencies: From a0aed8a19fe4c0e9bc29f5a842403526d74d064e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:30:09 +0800 Subject: [PATCH 017/114] fix(examples): own repository plugin startup order --- .../headless-agent/tests/fixtures/cli.cordis.yml | 5 +---- .../tests/fixtures/repository-plugin/load.mjs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 examples/headless-agent/tests/fixtures/repository-plugin/load.mjs diff --git a/examples/headless-agent/tests/fixtures/cli.cordis.yml b/examples/headless-agent/tests/fixtures/cli.cordis.yml index 5e1a296de5..72e71ec775 100644 --- a/examples/headless-agent/tests/fixtures/cli.cordis.yml +++ b/examples/headless-agent/tests/fixtures/cli.cordis.yml @@ -1,11 +1,8 @@ - id: cli-mock-llm name: './cli-mock-llm.ts' -- id: repository-plugin-runtime - name: '@deepseek-ai/dsh-repository-plugin' - - id: repository-plugin-fixture - name: './repository-plugin/dsh-plugin.mjs' + name: './repository-plugin/load.mjs' - id: base name: '@cordisjs/plugin-include' diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs new file mode 100644 index 0000000000..90f759876a --- /dev/null +++ b/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs @@ -0,0 +1,13 @@ +/** + * Keyless fixture owner that mounts the runtime before its prepared wrapper. + * Cordis starts sibling Loader entries concurrently, so row order is not a dependency edge. + */ +import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin' +import * as PreparedPlugin from './dsh-plugin.mjs' + +export const name = 'headless-repository-fixture-loader' + +export async function apply(ctx) { + await ctx.plugin(RepositoryPlugin) + await ctx.plugin(PreparedPlugin) +} From 0664b25cd955aa8b4f5a80bc29ca4c872ba4802e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:16:23 +0800 Subject: [PATCH 018/114] fix(review): validate skill roots at mount and isolate provider default roots ds-review-bot round 1 on the repository-plugin runtime: - a manifest-declared skill root absent or non-directory in the installed package now fails the plugin load (skill-local treats a missing root as legitimately empty, which silently mounted a skill-less plugin) - includeDefaultRoots: false no longer inherits $DSH_BUNDLED_SKILL_DIR, so isolated repository providers see only their explicit roots - prepared wrapper baseUrl schema requires the file: scheme, failing hostile URLs at the declared validation boundary - preparedPath reuses format.ts's isOutside; SERVER_NAME_PATTERN is exported and pinned equal to dsh-mcp-client's, with the restatement justified (the prepare bin keeps a zod-only module graph); the unexplained `as never` cast now carries its schemastery rationale - the import-free wrapper assertion also rejects dynamic import( - the headless fixture wrapper is regenerated by the real prepareDshPlugin and a drift test pins fixture == generator output - prepareDshPlugin JSDoc states the non-atomic publish repair contract --- ...-static-repository-plugin-format.i18n.yaml | 4 +-- ...6-07-30-static-repository-plugin-format.md | 2 +- ...7-30-static-repository-plugin-format.zh.md | 2 +- docs/config-catalog.md | 4 +-- .../fixtures/repository-plugin/dsh-plugin.mjs | 10 +++--- .../headless-agent/tests/keyless-smoke.e2e.ts | 28 +++++++++++++++- .../cordis/repository-plugin/README.i18n.yaml | 4 +-- packages/cordis/repository-plugin/README.md | 2 +- .../cordis/repository-plugin/README.zh.md | 2 +- .../cordis/repository-plugin/src/format.ts | 31 ++++++++++++++++-- .../cordis/repository-plugin/src/index.ts | 31 ++++++++++++++---- packages/cordis/repository-plugin/src/mcp.ts | 11 +++++-- .../tests/mcp-format.spec.ts | 10 +++++- .../tests/repository-plugin.spec.ts | 32 ++++++++++++++++++- packages/mcp/mcp-client/src/index.ts | 5 ++- packages/skill/skill-local/README.i18n.yaml | 4 +-- packages/skill/skill-local/README.md | 2 +- packages/skill/skill-local/README.zh.md | 2 +- packages/skill/skill-local/src/index.ts | 9 ++++-- .../skill-local/tests/skill-local.spec.ts | 16 ++++++++++ 20 files changed, 175 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml index 0190e19f8e..b3fbb54f35 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.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/architecture/2026-07-30-static-repository-plugin-format.md -2026-07-30-static-repository-plugin-format.md: c4739a6843db515d4cd67441e74bbf05228c612a -2026-07-30-static-repository-plugin-format.zh.md: 0b0a2af132137a820ba941869a612b7f28754fcb +2026-07-30-static-repository-plugin-format.md: f31728e28ddbb8e6403f327cb5b7c7533b214129 +2026-07-30-static-repository-plugin-format.zh.md: ec2295579353632a605aeb4eb2da7a39cfc4b23a diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md index c4739a6843..f31728e28d 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -14,7 +14,7 @@ The [package-manager-native repository cache](2026-07-30-package-manager-native- `@deepseek-ai/dsh-repository-plugin` owns a restricted `.dsh-plugin` package format with two contribution kinds only: skill roots and one common `.mcp.json`. Its package metadata uses `package.json#dsh.skills` for relative skill-root paths and `package.json#dsh.mcpServers` for the relative MCP document path. At least one is required. Each path may leave `.dsh-plugin` to reuse repository content but must remain beneath the directory containing that `.dsh-plugin`; a nested selectable Plugin therefore owns the adjacent subtree above its package without gaining access to unrelated host paths. -The `.dsh-plugin` package declares `dsh-plugin-prepare` as its ordinary package-manager `prepare` script. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The `.mjs` extension avoids imposing `type: module` on repository-authored package metadata. The generated module is a fixed import-free template containing only a normalized manifest, `inject = ['loader']`, and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point. +The `.dsh-plugin` package declares `dsh-plugin-prepare` as its ordinary package-manager `prepare` script. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The `.mjs` extension avoids imposing `type: module` on repository-authored package metadata. The generated module is a fixed import-free template containing only a normalized manifest, an `inject` list derived from it (`loader`, plus `skills` and/or `tools` per the declared capabilities, so the wrapper fiber gates on the services its children need), and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point. Loading the DSH package registers that builtin as an effect. A generated wrapper mounts the builtin as its child with `import.meta.url`, so all contributions belong to the wrapper fiber and disappear on Loader removal or rollback. The builtin revalidates the prepared manifest and path containment before reading assets. It composes the existing implementations rather than registering skills or MCP tools itself. diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md index 0b0a2af132..ec22955793 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md @@ -14,7 +14,7 @@ `@deepseek-ai/dsh-repository-plugin` 负责一个受限的 `.dsh-plugin` package 格式,且只允许两类贡献:skill 根和一个通用 `.mcp.json`。Package metadata 使用 `package.json#dsh.skills` 声明相对 skill 根路径,使用 `package.json#dsh.mcpServers` 声明相对 MCP 文档路径;两者至少需要一个。路径可以离开 `.dsh-plugin` 以复用仓库内容,但必须留在包含该 `.dsh-plugin` 的目录之下;因此,一个嵌套且可选择的 Plugin 可以拥有其 package 上方相邻的子树,却不能访问无关宿主路径。 -`.dsh-plugin` package 把 `dsh-plugin-prepare` 声明为普通 package-manager `prepare` 脚本。Helper 会校验 metadata 与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。`.mjs` 扩展名避免强迫仓库作者在 package metadata 中设置 `type: module`。生成模块来自固定、无 import 的模板,只包含规范化 manifest、`inject = ['loader']`,以及对 `dsh-repository-plugin` Loader builtin 的委托。准备阶段永远不会发现、转译、打包或保留自定义仓库入口。 +`.dsh-plugin` package 把 `dsh-plugin-prepare` 声明为普通 package-manager `prepare` 脚本。Helper 会校验 metadata 与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。`.mjs` 扩展名避免强迫仓库作者在 package metadata 中设置 `type: module`。生成模块来自固定、无 import 的模板,只包含规范化 manifest、由 manifest 派生的 `inject` 列表(`loader`,加上按声明能力加入的 `skills`/`tools`,使包装 fiber 在其子插件所需服务上门控),以及对 `dsh-repository-plugin` Loader builtin 的委托。准备阶段永远不会发现、转译、打包或保留自定义仓库入口。 加载 DSH package 会以 effect 方式注册该 builtin。生成的包装模块使用 `import.meta.url` 把 builtin 挂载为自己的子级,因此所有贡献都归属于包装 fiber,并在 Loader 移除或回滚时消失。Builtin 会在读取资源前重新校验已准备 manifest 与路径包含关系。它只组合现有实现,而不自行注册 skills 或 MCP 工具。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 457f1be7b8..14ce334f48 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -867,7 +867,7 @@ export interface StreamableHttpConfig { } ``` -Source: [`packages/mcp/mcp-client/src/index.ts:93`](../packages/mcp/mcp-client/src/index.ts) +Source: [`packages/mcp/mcp-client/src/index.ts:96`](../packages/mcp/mcp-client/src/index.ts) ## `@deepseek-ai/dsh-permission` @@ -1334,7 +1334,7 @@ export interface Config { watchMaxProjects?: number /** Whether watched symbolic links follow their target files. */ watchFollowSymlinks?: boolean - /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ + /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR` when default roots are included, otherwise mounts none. */ bundledSkillDir?: string } ``` diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs index 31225c0afc..5515aa82a2 100644 --- a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs +++ b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs @@ -1,9 +1,9 @@ // Generated by dsh-plugin-prepare. Do not edit. -const manifest = { "name": "headless-repository-fixture", "skills": ["dsh-plugin-assets/skills/0"] } -export const name = 'headless-repository-fixture' -export const inject = ['loader'] +const manifest = {"name":"headless-repository-fixture","skills":["dsh-plugin-assets/skills/0"]} +export const name = "headless-repository-fixture" +export const inject = ["loader","skills"] export async function apply(ctx) { - const runtime = ctx.loader.builtins['dsh-repository-plugin'] - if (runtime === undefined) throw new Error('missing Cordis builtin dsh-repository-plugin') + const runtime = ctx.loader.builtins["dsh-repository-plugin"] + if (runtime === undefined) throw new Error("missing Cordis builtin dsh-repository-plugin") await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest }) } diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index d5e1cce826..4e18e177c3 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -1,10 +1,12 @@ -import { readFile, readdir } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { zstdDecompress } from 'node:zlib' import { promisify } from 'node:util' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin' import type { SessionEvent } from '@deepseek-ai/dsh-session' const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) @@ -59,4 +61,28 @@ describe('headless-agent keyless smoke', () => { expect(String(result?.['result'])).toContain('CLI_TOOL_ROUND_TRIP') expect(persistedHeader).toMatchObject({ type: 'session' }) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('keeps the checked-in prepared wrapper identical to the generator output for its manifest', async () => { + // The fixture claims "Generated by dsh-plugin-prepare"; this pin makes the + // claim true — a wrapper-template change fails here until the fixture is + // regenerated, so the assembled smoke can never exercise a stale shape. + const fixture = fileURLToPath(new URL('./fixtures/repository-plugin/', import.meta.url)) + const root = await mkdtemp(join(tmpdir(), 'dsh-fixture-drift-')) + try { + const plugin = join(root, '.dsh-plugin') + await mkdir(plugin, { recursive: true }) + await cp(join(fixture, 'dsh-plugin-assets/skills/0'), join(root, 'skills'), { recursive: true }) + await writeFile(join(plugin, 'package.json'), `${JSON.stringify({ + name: 'headless-repository-fixture', + version: '0.0.0', + dsh: { skills: ['../skills'] }, + }, undefined, 2)}\n`) + await prepareDshPlugin(plugin) + const generated = await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8') + const checkedIn = await readFile(join(fixture, PREPARED_ENTRY_FILENAME), 'utf8') + expect(checkedIn).toBe(generated) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) }) diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml index 806ee1c3af..52f506c37c 100644 --- a/packages/cordis/repository-plugin/README.i18n.yaml +++ b/packages/cordis/repository-plugin/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/cordis/repository-plugin/README.md -README.md: dab6287304f083e5c0ae128d5a3cb861332c076a -README.zh.md: 790e601ad022ffa20c7d02a88353e972bf8bffe2 +README.md: 80744eb489d1714f59ba6e53207476a8ce222e24 +README.zh.md: d297b44e4a065fa99865e3a42d2c823c7b7c5848 diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md index dab6287304..80744eb489 100644 --- a/packages/cordis/repository-plugin/README.md +++ b/packages/cordis/repository-plugin/README.md @@ -36,7 +36,7 @@ The containing package manager still runs the configured repository package's li ## Runtime composition -Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates to that builtin with its own module URL and prepared manifest. Repository skill roots mount as a uniquely named `dsh-skill-local` provider with default project/user roots excluded and watching disabled; cached package generations are immutable. Wrapper disposal removes the provider and all composed MCP clients through normal Cordis child-fiber teardown. +Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates to that builtin with its own module URL and prepared manifest. The runtime validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped (a `files`/`.npmignore` mistake, a damaged cache entry) fails the plugin load instead of silently mounting a skill-less plugin. Repository skill roots mount as a uniquely named `dsh-skill-local` provider with default project/user roots excluded and watching disabled; cached package generations are immutable. Wrapper disposal removes the provider and all composed MCP clients through normal Cordis child-fiber teardown. ## Common MCP format diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md index 790e601ad0..d297b44e4a 100644 --- a/packages/cordis/repository-plugin/README.zh.md +++ b/packages/cordis/repository-plugin/README.zh.md @@ -36,7 +36,7 @@ ## 运行时组合 -加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。 +加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。运行时在挂载前会校验每个声明的 skill 根都是包内实际存在的目录——生成输出被丢弃的包(`files`/`.npmignore` 配置失误、缓存条目损坏)会使插件加载失败,而不是静默挂载一个没有 skill 的插件。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。 ## 通用 MCP 格式 diff --git a/packages/cordis/repository-plugin/src/format.ts b/packages/cordis/repository-plugin/src/format.ts index 0c00142383..9d66321087 100644 --- a/packages/cordis/repository-plugin/src/format.ts +++ b/packages/cordis/repository-plugin/src/format.ts @@ -31,7 +31,10 @@ const preparedManifestSchema = z.object({ mcpServers: z.string().min(1).optional(), }).strict() const preparedConfigSchema = z.object({ - baseUrl: z.url(), + // Wrappers pass import.meta.url, which is always file: for an installed + // package; any other scheme would only fail later inside fileURLToPath with + // an uncontextualized TypeError, so reject it at this validation boundary. + baseUrl: z.url({ protocol: /^file$/ }), manifest: preparedManifestSchema, }).strict() @@ -70,7 +73,14 @@ export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig } } -function isOutside(root: string, candidate: string): boolean { +/** + * Whether `candidate` resolves outside `root` — the containment check shared + * by prepare-time asset copying and runtime prepared-path resolution. + * @param root - directory that must contain the candidate. + * @param candidate - absolute path to test. + * @returns true when the candidate escapes the root. + */ +export function isOutside(root: string, candidate: string): boolean { const path = relative(root, candidate) /* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */ return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) @@ -95,11 +105,22 @@ async function sourcePath(pluginDirectory: string, sourceRoot: string, configure } function wrapperSource(manifest: PreparedPluginManifest): string { + // The manifest is static, so the wrapper's service dependencies are too: + // declaring them gates the wrapper fiber until the composition provides + // them, which means the runtime's SkillLocal/McpClient children activate + // within the wrapper's own load epoch and their failures (duplicate + // provider names, damaged packages) reject the wrapper's Loader + // transaction instead of leaving a silently PENDING or FAILED child. + const inject = [ + 'loader', + ...manifest.skills.length > 0 ? ['skills'] : [], + ...manifest.mcpServers === undefined ? [] : ['tools'], + ] return [ '// Generated by dsh-plugin-prepare. Do not edit.', `const manifest = ${JSON.stringify(manifest)}`, `export const name = ${JSON.stringify(manifest.name)}`, - "export const inject = ['loader']", + `export const inject = ${JSON.stringify(inject)}`, 'export async function apply(ctx) {', ` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`, ` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`, @@ -111,6 +132,10 @@ function wrapperSource(manifest: PreparedPluginManifest): string { /** * Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper. + * Outputs are staged and committed by rename, but the final publish (remove + * old outputs, rename assets, rename entry) is not one atomic step: a crash + * mid-publish can leave assets without an entry or neither. Rerunning prepare + * repairs the package; partial outputs are never importable as a plugin. * @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd. * @returns the generated static manifest. */ diff --git a/packages/cordis/repository-plugin/src/index.ts b/packages/cordis/repository-plugin/src/index.ts index 50a76fc952..26922654bb 100644 --- a/packages/cordis/repository-plugin/src/index.ts +++ b/packages/cordis/repository-plugin/src/index.ts @@ -3,8 +3,8 @@ * @module @deepseek-ai/dsh-repository-plugin */ -import { readFile } from 'node:fs/promises' -import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { readFile, stat } from 'node:fs/promises' +import { dirname, isAbsolute, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' import type {} from '@cordisjs/plugin-loader' @@ -12,6 +12,7 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as McpClient from '@deepseek-ai/dsh-mcp-client' import { REPOSITORY_PLUGIN_BUILTIN, + isOutside, parsePreparedPluginConfig, type PreparedPluginConfig, } from './format.ts' @@ -34,24 +35,42 @@ function preparedPath(baseUrl: string, configured: string): string { if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`) const directory = dirname(fileURLToPath(baseUrl)) const path = resolve(directory, configured) - const rel = relative(directory, path) - /* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */ - if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + if (isOutside(directory, path)) { throw new Error(`prepared DSH plugin path escapes its package: ${JSON.stringify(configured)}`) } return path } +async function preparedDirectory(baseUrl: string, configured: string): Promise { + const path = preparedPath(baseUrl, configured) + // A manifest-declared skill root missing from the installed package (files/ + // .npmignore dropping generated outputs, a damaged cache entry) must fail + // the plugin load: the skill provider treats an absent root as legitimately + // empty, which would silently mount a skill-less plugin. + let info + try { + info = await stat(path) + } catch (cause) { + throw new Error(`prepared DSH plugin skill root is missing from the installed package: ${JSON.stringify(configured)}`, { cause }) + } + if (!info.isDirectory()) { + throw new Error(`prepared DSH plugin skill root is not a directory: ${JSON.stringify(configured)}`) + } + return path +} + async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise { const config = parsePreparedPluginConfig(value) const directory = dirname(fileURLToPath(config.baseUrl)) - const skillDirectories = config.manifest.skills.map(path => preparedPath(config.baseUrl, path)) + const skillDirectories = await Promise.all(config.manifest.skills.map(path => preparedDirectory(config.baseUrl, path))) const mcpConfigs = config.manifest.mcpServers === undefined ? [] : resolveMcpServers( parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')), process.env, directory, + // Schemastery call signatures collapse the parameter to `never` under + // NodeNext; ResolvedMcpServer is shaped for the Config union by design. ).map(input => McpClient.Config(input as never)) await ctx.effect(async function* () { diff --git a/packages/cordis/repository-plugin/src/mcp.ts b/packages/cordis/repository-plugin/src/mcp.ts index 893d96f086..bce8179121 100644 --- a/packages/cordis/repository-plugin/src/mcp.ts +++ b/packages/cordis/repository-plugin/src/mcp.ts @@ -5,7 +5,14 @@ import { z } from 'zod' -const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ +/** + * Restates dsh-mcp-client's `SERVER_NAME_PATTERN` rather than importing it: + * the prepare bin must stay a zod-only module graph (no tools seam, no MCP + * SDK). Exported so `repository-plugin.spec.ts` pins equality with the + * client's exported pattern — prepare-time validation cannot drift from the + * registry that enforces uniqueness. + */ +export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ const PLACEHOLDER_PATTERN = /\$\{([^}]*)\}/g @@ -89,7 +96,7 @@ export function parseMcpDocument(content: string): McpDocument { if (!result.success) throw new Error(`invalid .mcp.json:\n${z.prettifyError(result.error)}`) for (const [serverName, definition] of Object.entries(result.data.mcpServers)) { if (!SERVER_NAME_PATTERN.test(serverName)) { - throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match [A-Za-z0-9_-]{1,32}`) + throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match ${SERVER_NAME_PATTERN.source}`) } visitStrings(serverName, definition, assertTemplate) } diff --git a/packages/cordis/repository-plugin/tests/mcp-format.spec.ts b/packages/cordis/repository-plugin/tests/mcp-format.spec.ts index 094cb93020..5251ccdbc2 100644 --- a/packages/cordis/repository-plugin/tests/mcp-format.spec.ts +++ b/packages/cordis/repository-plugin/tests/mcp-format.spec.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from 'vitest' -import { parseMcpDocument, resolveMcpServers } from '../src/mcp.ts' +import { SERVER_NAME_PATTERN as CLIENT_SERVER_NAME_PATTERN } from '@deepseek-ai/dsh-mcp-client' +import { SERVER_NAME_PATTERN, parseMcpDocument, resolveMcpServers } from '../src/mcp.ts' describe('repository plugin common .mcp.json support', () => { + it('validates server names with exactly the pattern the MCP client registry enforces', () => { + // mcp.ts restates the pattern to keep the prepare bin's module graph + // zod-only; this pin is the drift guard. + expect(SERVER_NAME_PATTERN.source).toBe(CLIENT_SERVER_NAME_PATTERN.source) + expect(SERVER_NAME_PATTERN.flags).toBe(CLIENT_SERVER_NAME_PATTERN.flags) + }) + it('maps Expo-style HTTP servers to the existing Streamable HTTP client config', () => { const document = parseMcpDocument(JSON.stringify({ mcpServers: { diff --git a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts index f8e5807501..e26f267e72 100644 --- a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts @@ -59,7 +59,9 @@ describe('dsh-plugin-prepare', () => { }) const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8') expect(wrapper).toContain(`ctx.loader.builtins["${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}"]`) - expect(wrapper).not.toMatch(/\b(?:import|from)\s/) + // Import-free means no static AND no dynamic imports; `import.meta.url` + // (no whitespace, no call parenthesis) is the one allowed appearance. + expect(wrapper).not.toMatch(/\b(?:import|from)\s|\bimport\s*\(/) await expect(readFile(join(directory, 'dsh-plugin-assets/skills/0/repository-fixture/SKILL.md'), 'utf8')) .resolves.toContain('Static instructions.') await expect(readFile(join(directory, 'dsh-plugin-assets/.mcp.json'), 'utf8')) @@ -218,6 +220,34 @@ describe('prepared repository plugin Loader composition', () => { await ctx.fiber.dispose() }) + it('fails the plugin load when a declared skill root is missing or not a directory', async () => { + const root = await temporaryDirectory('missing-skill-root') + await writeFile(join(root, 'not-a-directory'), 'text') + const ctx = new Context() + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(SkillService) + await ctx.plugin(RepositoryPlugin) + + for (const [filename, skillPath, message] of [ + ['missing.mjs', 'dsh-plugin-assets/skills/0', 'skill root is missing from the installed package'], + ['file.mjs', 'not-a-directory', 'skill root is not a directory'], + ] as const) { + const wrapper = join(root, filename) + await writeFile(wrapper, [ + "export const inject = ['loader']", + 'export async function apply(ctx) {', + ` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`, + ` baseUrl: import.meta.url, manifest: { name: 'damaged', skills: [${JSON.stringify(skillPath)}] },`, + ' })', + '}', + '', + ].join('\n')) + await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow(message) + } + await ctx.fiber.dispose() + }) + it('rejects duplicate builtin ownership and preserves a later replacement on teardown', async () => { const ctx = new Context() await ctx.plugin(Loader) diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 1eec41b96e..49609bac9a 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -36,8 +36,11 @@ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000 /** * Valid `serverName`: 1–32 chars of `[A-Za-z0-9_-]`. Kept well under the * 64-char public-name budget so typical raw tool names survive unhashed. + * Exported so upstream producers of Config inputs (repository-plugin's + * `.mcp.json` prepare-time validation) reject the same names this registry + * would. */ -const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ +export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ /** * Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index 1902122c68..269054f9f8 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/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/skill-local/README.md -README.md: 836a2a631e9e6e452a11e3cffc102de355f1c5d9 -README.zh.md: 2e2cc45ad80f760e04f813b7ee85932b51b1df05 +README.md: f85cc2e6fd0c32cb88f28a2914a03e22b3a20657 +README.zh.md: 73a66831ad14b7edb346227cf6adb52ec8247fd7 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 836a2a631e..f85cc2e6fd 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -38,7 +38,7 @@ Default roots are resolved in this provider's rank order: | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits both project and user rows while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers such as immutable repository Plugins. This provider supplies project and user skills; another provider may supply built-in system skills. +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits the project and user rows and the `$DSH_BUNDLED_SKILL_DIR` environment default while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers such as immutable repository Plugins to see only their own roots. This provider supplies project and user skills; another provider may supply built-in system skills. When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 2e2cc45ad8..73a66831ad 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -38,7 +38,7 @@ | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目和用户两类根,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个唯一命名的隔离提供方,例如不可变 repository Plugin。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 +项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方,例如不可变 repository Plugin。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;格式错误或非文本条目会警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index aa07443a56..d6df41237e 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -69,7 +69,7 @@ export interface Config { watchMaxProjects?: number /** Whether watched symbolic links follow their target files. */ watchFollowSymlinks?: boolean - /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ + /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR` when default roots are included, otherwise mounts none. */ bundledSkillDir?: string } @@ -165,7 +165,12 @@ export class LocalSkillProvider implements SkillProvider { this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config)) control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true }) - const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR + // The environment bundled root is a default root: an isolated provider + // (includeDefaultRoots: false — repository plugins) must see only its + // explicit custom roots, or every such provider would re-discover the + // app's bundled skills and claim them under its own provider name. + const bundledSkillDir = config.bundledSkillDir + ?? (this.includeDefaultRoots ? process.env.DSH_BUNDLED_SKILL_DIR : undefined) this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir) } diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index ae57df3bca..0f92cac1d6 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -820,6 +820,22 @@ describe('LocalSkillProvider', () => { await ctx.plugin(SkillLocal, { watch: false }) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill']) + // Isolated providers see only their explicit roots: the environment + // bundled root is a default root, so includeDefaultRoots: false must + // drop it — repository providers never re-claim the app's builtins. + const isolated = new Context() + await isolated.plugin(SkillService) + const customOnly = join(envHome, 'custom-only') + await writeSkill(customOnly, 'custom-isolated-skill', 'Custom isolated skill') + await isolated.plugin(SkillLocal, { + providerName: 'isolated', + includeDefaultRoots: false, + customSkillDirs: [customOnly], + watch: false, + }) + expect((await isolated.skills.list()).map(skill => skill.name)).toEqual(['custom-isolated-skill']) + await isolated.fiber.dispose() + process.env.DSH_HOME = join(envHome, 'empty-dsh') delete process.env.DSH_BUNDLED_SKILL_DIR process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents') From 24484968037aa3ee00c402a72c6b942effb2cb0c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:00:53 +0800 Subject: [PATCH 019/114] feat: configure repository plugins from DSH home --- ...-static-repository-plugin-format.i18n.yaml | 4 +- ...6-07-30-static-repository-plugin-format.md | 2 +- ...7-30-static-repository-plugin-format.zh.md | 2 +- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 16 +-- .../2026-07-20-dsh-cli-personal-config.zh.md | 16 +-- ...0-config-only-repository-plugins.i18n.yaml | 6 + ...26-07-30-config-only-repository-plugins.md | 50 ++++++++ ...07-30-config-only-repository-plugins.zh.md | 50 ++++++++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 14 ++- apps/cli/README.zh.md | 14 ++- apps/cli/composition.md | 3 + apps/cli/config/base.cordis.yml | 7 ++ apps/cli/package.json | 1 + apps/cli/src/app-cli-entry.ts | 38 ++++-- apps/cli/src/headless.ts | 1 + apps/cli/src/tui.ts | 15 ++- apps/cli/src/web.ts | 1 + apps/cli/tests/tui-keyless-smoke.e2e.ts | 56 +++++++++ docs/config-catalog.md | 17 ++- docs/module-graph.md | 3 +- knip.json | 6 +- .../cordis/repository-plugin/README.i18n.yaml | 4 +- packages/cordis/repository-plugin/README.md | 17 +++ .../cordis/repository-plugin/README.zh.md | 17 +++ .../cordis/repository-plugin/package.json | 2 + .../cordis/repository-plugin/src/index.ts | 35 +++++- .../cordis/repository-plugin/src/source.ts | 74 +++++++++++ .../tests/repository-plugin.spec.ts | 119 +++++++++++++++++- .../cordis/repository-plugin/tsconfig.json | 3 + packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 6 +- packages/ui/app-boot/README.zh.md | 6 +- packages/ui/app-boot/package.json | 6 + packages/ui/app-boot/src/index.ts | 99 ++++++++++++--- .../ui/app-boot/tests/personal-config.spec.ts | 101 ++++++++++++++- .../app-boot/tests/repository-cache.spec.ts | 27 +++- packages/ui/app-boot/tsconfig.json | 3 + pnpm-lock.yaml | 6 + 40 files changed, 783 insertions(+), 76 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md create mode 100644 packages/cordis/repository-plugin/src/source.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml index b3fbb54f35..6319b99fb2 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.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/architecture/2026-07-30-static-repository-plugin-format.md -2026-07-30-static-repository-plugin-format.md: f31728e28ddbb8e6403f327cb5b7c7533b214129 -2026-07-30-static-repository-plugin-format.zh.md: ec2295579353632a605aeb4eb2da7a39cfc4b23a +2026-07-30-static-repository-plugin-format.md: c9d755b925a6ea05eed71e75803397d2672df9f4 +2026-07-30-static-repository-plugin-format.zh.md: 361de64d2e98b9fb4ac42963e4ae48e77fbc7016 diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md index f31728e28d..c9d755b925 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md +++ b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md @@ -22,7 +22,7 @@ Each prepared skill set mounts `dsh-skill-local` with a unique `repository: project `.env` > personal `.env`. -- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op. +- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op. The [repository Plugin integration](2026-07-30-config-only-repository-plugins.md) uses one shipped row to make an exact GitHub source list a config-only choice. - A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip). The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes. -Hot-reload interplay: the include re-applies its `patches` on every config re-read (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)), so a live `cordis.yml` edit keeps the personal overlay applied. +The TUI and Web register the exact personal path through Cordis HMR after boot. Every add, change, or removal transactionally recomposes the full patch list through the launcher's own composition closure, so the fresh personal patches land in the same layer position they booted in. Invalid YAML or a rejected Loader candidate leaves the last good tree active and broadcasts `hmr/config-update-failed(filename, Error)`; the headless surface reads the file once at startup. The Include also re-applies its patches on committed config-file refreshes (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)). ## Alternatives considered -**A standalone `bin/dsh` wrapper owning the `dsh` name.** Rejected after reading PR #443: that PR establishes `apps/cli` as the `dsh` CLI with subcommand dispatch (`web`, `-p`) and leaves the default slot unclaimed. Two competing `dsh` entrypoints would collide in `$PATH` and in product identity; claiming the default slot inside the same package shape confines the eventual merge conflict to the small dispatch chain. +**A separate `bin/dsh` wrapper owning the `dsh` name.** Rejected because `apps/cli` is the single product CLI for default TUI, headless, and Web dispatch. Two competing entrypoints would collide in `$PATH` and product identity. **A pi-style typed settings file (`defaultProvider`/`defaultModel`/`providers`).** Rejected by the user in favor of patch semantics: the personal file is a cordis overlay over the shipped default config, not a second config vocabulary to own and translate. @@ -38,12 +38,12 @@ Hot-reload interplay: the include re-applies its `patches` on every config re-re ## Consequences -- `dsh` from any directory (and `pnpm run demo:tui`) boots the personal provider/model with zero repo changes; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. +- `dsh` from any directory (and `pnpm run demo:tui`) can apply personal providers, models, repository Plugins, and other Loader entries with no checkout edit; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. - Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](2026-07-30-dsh-dump-config.md) (which prints the composed tree those patches produce) are the diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. - `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`. -- When PR #443 lands, `apps/cli/src/bin.ts`'s dispatch chain and `apps/cli/package.json`'s dependency list conflict textually; both resolve as unions (their `web`/`-p` branches plus our default-TUI branch). +- Live watching belongs only to long-running TUI and Web processes. Headless automation gets deterministic startup configuration and exits without retaining a watcher. ## Testing -`packages/ui/app-boot/tests/personal-config.spec.ts` pins `!!js` preservation and end-to-end interpolation through a booted tree, insert entries, the default directory resolving from `$DSH_HOME`, the absent/empty no-op paths, and the three fail-loud shapes (unreadable, unparsable, non-array). `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the dsh bin in a PTY three ways: default config with no overlay, a personal `.env` + `config.yaml` chain whose patched welcome renders in the banner, and an invalid personal file failing the boot loudly. The pre-existing smokes and snapshot suites pass on a machine whose real `~/.dsh` overlay would change the booted model — the isolation, not luck. +`packages/ui/app-boot/tests/personal-config.spec.ts` pins parsing, startup application, exact-path add/failure/recovery/removal, last-good rollback, failure broadcast, and preservation of app-owned patches. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real dsh bin with no overlay, a personal environment and UI patch, a config-only cached repository skill, and invalid personal YAML. Test launchers isolate `$DSH_HOME`, so a developer's real overlay cannot leak into fixtures. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 8f7c15c3c6..e70b8914cf 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -12,21 +12,21 @@ Status: implemented 两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐: -**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,通过 Node 的原生 TypeScript 转换和应用自身持有的 tsconfig-paths loader **从源码**运行该 bin,因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。 +**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace,启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,并使用 tsx 的 ESM hook 运行应用;该契约由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)维护。`pnpm run demo:tui` 运行同一入口。 -**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的官方界面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: +**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI、Web 和无头界面使用其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: - `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`。 -- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。 +- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。[仓库插件集成](2026-07-30-config-only-repository-plugins.md)通过一个已交付配置项,使精确 GitHub 源列表成为纯配置选择。 - 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。 PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。 -与热重载的交互:include 在每次配置重读时重新应用其 `patches`(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)),因此运行中编辑 `cordis.yml` 后个人 overlay 仍保持生效。 +TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人配置路径。每次新增、变更或移除都会以事务方式通过启动器自己的组合闭包重新组合完整 patch 列表,因此新的个人 patch 落在启动时相同的层次位置。YAML 无效或 Loader 候选被拒时,最后一个可用树保持活动状态,并广播 `hmr/config-update-failed(filename, Error)`;无头界面只在启动时读取该文件。Include 在已提交配置文件刷新时也会重新应用其 patch(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md))。 ## Alternatives considered -**独立的 `bin/dsh` 包装脚本占有 `dsh` 这个名字。** 读过 PR #443 后否决:该 PR 把 `apps/cli` 确立为带子命令分发(`web`、`-p`)的 `dsh` CLI,并且默认位空缺。两个互相竞争的 `dsh` 入口会在 `$PATH` 和产品身份上冲突;在同一包形态内认领默认位,把最终的合并冲突限制在小小的分发链上。 +**另设一个 `bin/dsh` 包装脚本并由其占用 `dsh` 名称。** 否决,因为 `apps/cli` 是统一的产品 CLI,负责分发默认 TUI、无头和 Web 界面。两个相互竞争的入口会在 `$PATH` 和产品身份上冲突。 **pi 风格的类型化设置文件(`defaultProvider`/`defaultModel`/`providers`)。** 用户否决,选择补丁语义:个人文件是叠加在随仓库提供的默认配置之上的 cordis overlay,而不是需要另行拥有和翻译的第二套配置词汇。 @@ -38,12 +38,12 @@ PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录 ## Consequences -- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`)即可零仓库改动地使用个人提供方/模型;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 +- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`),无需修改 checkout,即可应用个人提供方、模型、仓库插件和其他 Loader 配置项;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 - 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](2026-07-30-dsh-dump-config.md)(打印这些补丁合成出的配置树)。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 - `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 -- PR #443 落地时,`apps/cli/src/bin.ts` 的分发链与 `apps/cli/package.json` 的依赖列表会产生文本冲突;两者都按并集解决(他们的 `web`/`-p` 分支加上我们的默认 TUI 分支)。 +- 只有长时间运行的 TUI 和 Web 进程进行实时监视。无头自动化使用确定性的启动配置,退出时不会保留 watcher。 ## Testing -`packages/ui/app-boot/tests/personal-config.spec.ts` 固定 `!!js` 的保留与经真实启动树的端到端插值、insert 配置项、默认目录从 `$DSH_HOME` 解析、缺失/为空的无操作路径,以及三种响亮失败形态(不可读、不可解析、非数组)。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里以三种方式启动 dsh bin:无 overlay 的默认配置、个人 `.env` + `config.yaml` 链条(打补丁的欢迎语渲染进横幅)、以及无效个人文件导致的响亮启动失败。既有冒烟与快照套件在一台真实 `~/.dsh` overlay 会改变启动模型的机器上通过——靠隔离,不靠运气。 +`packages/ui/app-boot/tests/personal-config.spec.ts` 固定解析、启动时应用、确切路径的新增/失败/恢复/移除、最后可用状态回滚、失败广播以及应用自有 patch 的保留。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 启动真实 dsh bin,覆盖无 overlay、个人环境与 UI patch、纯配置的缓存 repository skill,以及无效个人 YAML。测试启动器会隔离 `$DSH_HOME`,因此开发者的真实 overlay 不会泄漏进 fixture。 diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml new file mode 100644 index 0000000000..1491968f15 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md +2026-07-30-config-only-repository-plugins.md: 2057125fc78596dd4e5eb153f77b828f83d9ceff +2026-07-30-config-only-repository-plugins.zh.md: 6e741b46be716e21a11c2f508fb5e6d0505c76d0 diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md new file mode 100644 index 0000000000..2057125fc7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md @@ -0,0 +1,50 @@ +# Agent Note: Config-only repository Plugins for standalone dsh + +Status: implemented + +English | [中文](2026-07-30-config-only-repository-plugins.zh.md) + +## Problem + +A standalone `dsh` user has no developer-owned SDK project whose `package.json`, lockfile, and `cordis.yml` can carry an external Plugin dependency. Requiring an install command or another state file would make “use this repository” a multi-step workflow, while loading arbitrary repository code would bypass the restricted [static repository Plugin format](../architecture/2026-07-30-static-repository-plugin-format.md). Long-running TUI and Web processes also need a failed edit to preserve their usable Plugin generation and tell observers why the candidate was rejected. + +## Decision + +The shipped TUI and Web/headless `cordis.yml` trees contain an empty `repository-plugins` entry. A user changes only `$DSH_HOME/config.yaml`, replacing that entry's config with a `repositories` list. Each item uses `github:owner/repository#` plus an optional `&path:/.../.dsh-plugin`; omission selects `/.dsh-plugin`. An explicit ref is mandatory, paths are absolute within the repository and end in `.dsh-plugin`, and duplicate normalized specifiers reject before installation. There is no marketplace, discovery index, HTTPS URL vocabulary, or implicit latest generation. + +`@deepseek-ai/dsh-repository-plugin` validates and normalizes each source, then resolves it through the generic vendored [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md). The default cache is `$DSH_HOME/cache/repository-plugins`; `cacheDir` is the explicit deployment override. Bundled pnpm selects the configured repository subpackage, runs its ordinary lifecycle including `prepare`, and atomically publishes the exact specifier. The DSH host imports only the generated `dsh-plugin.mjs` wrapper and mounts it as a child fiber, so skills and MCP retain the owners, failure contracts, and teardown defined by the format package. + +## Live update and failure + +`dsh-app-boot` mounts the root Include through one helper that retains its exact Loader `Entry`. The TUI and Web register `$DSH_HOME/config.yaml` through Cordis HMR; headless reads the same file at startup without retaining a watcher. A watcher update rebuilds the Include patch list as immutable app-owned patches followed by the newly parsed personal patches, so Web-generated port, session-root, trust, and frontend values survive every personal edit unless a later personal patch deliberately replaces that row. + +Cordis serializes and coalesces exact-path changes. Include and Loader reconcile a candidate transactionally: success commits the new source list, while fetch, preparation, wrapper import, format, or child-Plugin failure rejects the candidate and retains or restores the last good tree. HMR normalizes the caught value to `Error`, logs it, and broadcasts the parallel `hmr/config-update-failed(filename, error)` event; observer failures cannot break refresh processing. MCP transport connection failure remains the existing MCP client's contained successful-Plugin/no-tools result and therefore is not reclassified as a config-update failure. + +An identical specifier permanently reuses its cache generation. HMR watches configuration, not cached repository code; the user changes the ref, path, or source list to select another generation. + +## Trust boundary + +Configuring a repository authorizes package-manager lifecycle code from that repository and its dependencies to run with the user's filesystem authority. The pnpm child removes ambient environment variables whose names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, but this is credential-exposure reduction rather than a sandbox. The fixed runtime wrapper prevents repository-authored Cordis entry points from becoming part of the supported Plugin format; it does not make package preparation untrusted-safe. + +## Alternatives considered + +**Require an SDK project dependency.** Rejected for the standalone app path because there is no project manifest to edit. Developer-owned SDK projects keep their native package-manager workflow as a separate capability. + +**Add a `dsh plugin install` command and installation database.** Rejected because the personal Loader overlay already owns machine-local composition. A second mutation interface and durable registry would duplicate config identity and rollback. + +**Resolve repositories directly in the DSH package.** Rejected because Git transport, GitHub subpackage selection, lifecycle execution, and content storage belong to pnpm and the generic Loader cache, not a DSH-specific adapter. + +**Watch cache contents or refresh the same ref automatically.** Rejected because one config value must identify one immutable prepared generation. Background remote resolution would change executable code without a config diff and make rollback depend on mutable remote state. + +**Broadcast an `unknown` failure payload.** Rejected at the HMR boundary. JavaScript may throw any value internally, but the public event always receives a normalized `Error`, giving observers one stable contract while retaining the original value as its cause when needed. + +## Consequences + +- A repository that adds `.dsh-plugin/package.json` can reach standalone users through one personal-config edit without changing its existing skills or `.mcp.json` layout. +- Long-running apps can add, replace, or remove configured generations without restart; rejected candidates retain the last good runtime and produce one generic Cordis event. +- First use may require Git/network access and preparation time. Later starts reuse the exact prepared cache; old generations consume disk until a separate cache-management policy exists. +- Only skills and common MCP definitions are supported. Hooks, commands, agents, apps, arbitrary Cordis code, compatibility shims, OAuth-bearing MCP definitions, and marketplaces remain intentionally absent. + +## Testing + +Repository-package tests pin source normalization, default and nested `.dsh-plugin` paths, cache-root resolution, duplicate rejection, prepared-wrapper loading, and disposal. App-boot tests drive exact-path add, two failure classes, recovery, removal, failure events, and generated-patch preservation through the real HMR/Include/Loader path. A keyless PTY smoke boots the shipped `dsh` composition from personal config alone and invokes a skill from a seeded immutable cache generation. diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md new file mode 100644 index 0000000000..6e741b46be --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 仅凭配置为独立 dsh 接入仓库插件 + +Status: implemented + +[English](2026-07-30-config-only-repository-plugins.md) | 中文 + +## 问题 + +独立 `dsh` 用户没有开发者自有的 SDK 项目,无法由其 `package.json`、lockfile 和 `cordis.yml` 承载外部插件依赖。若要求运行安装命令或维护另一份状态文件,「使用这个仓库」就会变成多步骤流程;若加载任意仓库代码,又会绕过受限的[静态仓库插件格式](../architecture/2026-07-30-static-repository-plugin-format.md)。长时间运行的 TUI 和 Web 进程还必须在编辑失败时保留仍可使用的插件版本,并向观察者说明候选配置被拒绝的原因。 + +## 决策 + +已交付的 TUI 和 Web/无头 `cordis.yml` 配置树包含一个空的 `repository-plugins` 配置项。用户只需修改 `$DSH_HOME/config.yaml`,用 `repositories` 列表替换该配置项的配置。每一项采用 `github:owner/repository#`,并可追加 `&path:/.../.dsh-plugin`;省略时选择 `/.dsh-plugin`。必须显式指定 ref;路径是仓库内的绝对路径,并以 `.dsh-plugin` 结尾;重复的规范化说明符在安装前即被拒绝。不提供插件市场、发现索引、HTTPS URL 词汇或隐式的最新版本。 + +`@deepseek-ai/dsh-repository-plugin` 校验并规范化每个源,再通过 vendor 中的通用 [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md) 解析。默认缓存位于 `$DSH_HOME/cache/repository-plugins`;`cacheDir` 是显式的部署覆盖项。随应用提供的 pnpm 选择配置的仓库子包(package),运行包括 `prepare` 在内的普通生命周期,并原子发布该精确说明符。DSH 宿主只导入生成的 `dsh-plugin.mjs` 包装模块并将其挂载为子 fiber,因此 skill(技能)与 MCP 仍沿用格式包定义的所有者、失败契约和清理行为。 + +## 实时更新与失败 + +`dsh-app-boot` 通过一个辅助函数挂载根 Include,并保留其确切的 Loader `Entry`。TUI 和 Web 通过 Cordis HMR(热模块替换)注册 `$DSH_HOME/config.yaml`;无头界面在启动时读取同一文件,但不保留监视器。监视器更新会重新构建 Include 补丁列表,先放置不可变的应用自有补丁,再放置新解析的个人补丁。因此,Web 生成的端口、会话根目录、信任和前端值会在每次个人编辑后保留,除非后续个人补丁有意替换相应配置项。 + +Cordis 会串行处理并合并该确切路径上的变更。Include 与 Loader 以事务方式协调候选配置:成功时提交新源列表;拉取、准备、包装模块导入、格式或子插件失败时拒绝候选配置,并保留或恢复最后一个可用树。HMR 会把捕获的值规范化为 `Error`,记录错误,并广播并行的 `hmr/config-update-failed(filename, error)` 事件;观察者失败不会中断刷新处理。MCP 传输连接失败仍沿用现有 MCP 客户端所收束的「插件成功加载但无工具」结果,因此不会被重新分类为配置更新失败。 + +相同说明符会永久复用同一个缓存版本。HMR 监视配置,而非已缓存的仓库代码;用户必须改变 ref、路径或源列表,才能选择另一个版本。 + +## 信任边界 + +配置仓库即授权该仓库及其依赖中的包管理器生命周期代码以用户的文件系统权限运行。pnpm 子进程会移除名称中含有 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的环境变量,但这只会减少凭据暴露,并非沙箱。固定的运行时包装模块会阻止仓库作者提供的 Cordis 入口成为受支持插件格式的一部分;它无法让包准备过程安全执行不受信任的代码。 + +## 考虑过的替代方案 + +**要求声明 SDK 项目依赖。** 独立应用路径没有可编辑的项目 manifest(元数据清单),因此否决。开发者自有的 SDK 项目仍可使用原生包管理器工作流,这是一项独立能力。 + +**新增 `dsh plugin install` 命令和安装数据库。** 否决,因为个人 Loader 覆盖层已经负责机器本地组合。第二个变更接口和持久注册表会重复配置身份与回滚机制。 + +**由 DSH 包直接解析仓库。** 否决,因为 Git 传输、GitHub 子包选择、生命周期执行和内容存储属于 pnpm 与通用 Loader 缓存,而非 DSH 专用适配器。 + +**监视缓存内容,或自动刷新相同 ref。** 否决,因为一个配置值必须标识一个不可变的已准备版本。后台远端解析会在没有配置差异的情况下改变可执行代码,并使回滚依赖可变的远端状态。 + +**广播 `unknown` 失败载荷。** 在 HMR 边界否决。JavaScript 内部可以抛出任意值,但公开事件始终接收规范化的 `Error`,从而为观察者提供稳定契约,并在需要时把原始值保留为错误原因。 + +## 后果 + +- 添加 `.dsh-plugin/package.json` 的仓库只需一次个人配置编辑即可供独立用户使用,无需改变现有 skill 或 `.mcp.json` 布局。 +- 长时间运行的应用无需重启即可新增、替换或移除已配置版本;被拒绝的候选配置会保留最后一个可用运行时,并产生一个通用 Cordis 事件。 +- 首次使用可能需要 Git/网络访问和准备时间。后续启动会复用这份精确的已准备缓存;在另行制定缓存管理政策之前,旧版本会持续占用磁盘空间。 +- 仅支持 skill 和通用 MCP 定义。钩子、命令、agent(智能体)、应用、任意 Cordis 代码、兼容 shim、带 OAuth 的 MCP 定义和插件市场均有意不提供。 + +## 测试 + +仓库包测试固定源规范化、默认和嵌套 `.dsh-plugin` 路径、缓存根解析、重复项拒绝、已准备包装模块加载及资源释放。App-boot 测试通过真实 HMR/Include/Loader 路径驱动确切路径的新增、两类失败、恢复、移除、失败事件及生成补丁保留。一个无密钥 PTY 冒烟测试仅通过个人配置启动已交付的 `dsh` 组合,并从预置的不可变缓存版本中调用一个 skill。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index cfee5f6662..96a4588f2c 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 6b67cbfccd21c6f1c32b5bc9417ab71309898b24 -README.zh.md: 71e242398fe56616c2b146562a6c7ce74fb0f6e1 +README.md: 76d9ed65398322cb9244a31661ee59b60c23f793 +README.zh.md: 16a7a4ec52b830e45c32a61a103d87be5941ab3b diff --git a/apps/cli/README.md b/apps/cli/README.md index 6b67cbfccd..76d9ed6539 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -11,7 +11,7 @@ The TUI surface: - resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; -- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. +- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. The shipped tree's Cordis HMR keeps `config.yaml` live; an explicit `--config` tree replaces that overlay, and a tree without HMR reads it at startup only. - presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context. - registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible. @@ -25,6 +25,18 @@ The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then The shared composition defaults new TUI, Web, and headless sessions to the `workspace-write` permission preset (`workspace-write` file mode plus `ask` approval policy). Sandbox-enforced bash and filesystem mutations may write only under the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. The browser answers one-shot approval requests and exposes the Access picker; the TUI exposes `/permission`, but has no approval-request answerer, so an automatic wider retry there fails closed until the user deliberately changes the session preset. `DSH_PERMISSION_MODE` changes the process fallback, while a stored General-settings Permission value applies to later sessions without changing an open one. +All three surfaces consume `$DSH_HOME/config.yaml`; the TUI and Web apply valid edits live, while one-shot headless runs read it at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command: + +```yaml +- id: repository-plugins + name: '@deepseek-ai/dsh-repository-plugin' + config: + repositories: + - 'github:PolyArch/humanize#' +``` + +The repository must contain a prepared `.dsh-plugin` package; the [repository Plugin contract](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration) documents authoring, nested Plugin paths, the immutable cache, trust boundary, and failure semantics. A failed live edit keeps the last good tree and emits Cordis's `hmr/config-update-failed` event. + The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 71e242398f..16a7a4ec52 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -11,7 +11,7 @@ TUI 界面: - 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; - 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; -- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。 +- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。已交付配置树中的 Cordis HMR 会持续应用 `config.yaml` 的变更;显式 `--config` 配置树会替代该个人覆盖,未包含 HMR 的配置树只在启动时读取该文件。 - 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 - 注册裸 `/compact`:agent 空闲时,即使未达到自动压力,也会摘要有效的较早历史;该命令拒绝参数,并只在独立替换标记对持久化后报告成功。压缩(compaction)期间提交的提示词保留其队列身份,并在该检查点之后启动;注入的上下文仍保持可见。 @@ -25,6 +25,18 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 共享组合把新建 TUI、Web 和无头会话的权限默认设为 `workspace-write` preset(`workspace-write` 文件模式加 `ask` 审批策略)。由沙箱强制约束的 bash 与文件系统修改只能写入会话工作区和平台临时根目录;读取、网络访问和进程可见性不受该策略约束。浏览器可以应答一次性审批请求,并提供 Access 选择器;TUI 提供 `/permission`,但没有审批请求应答者,因此自动请求更宽权限的重试会以拒绝方式关闭,直到用户主动更改会话 preset。`DSH_PERMISSION_MODE` 会更改进程回退值,而「通用」设置中已存储的「权限」值只适用于之后的会话,不会更改已打开的会话。 +三个界面都会使用 `$DSH_HOME/config.yaml`;TUI 和 Web 实时应用有效编辑,而一次性无头运行只在启动时读取。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只需配置即可添加已准备的 GitHub 插件: + +```yaml +- id: repository-plugins + name: '@deepseek-ai/dsh-repository-plugin' + config: + repositories: + - 'github:PolyArch/humanize#' +``` + +仓库必须包含已准备的 `.dsh-plugin` 包;[仓库插件契约](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)说明创作方式、嵌套插件路径、不可变缓存、信任边界和失败语义。实时编辑失败时,最后一个可用树保持运行,并发出 Cordis 的 HMR(热模块替换)事件 `hmr/config-update-failed`。 + 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index cb6b98a3b0..152b11d8c2 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_tui_timer plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] cfg --> plugin_tui_hmr + plugin_tui_repository_plugins["repository-plugins
@deepseek-ai/dsh-repository-plugin"] + cfg --> plugin_tui_repository_plugins plugin_tui_llm["llm
@deepseek-ai/dsh-llm"] cfg --> plugin_tui_llm plugin_tui_session["session
@deepseek-ai/dsh-session"] @@ -144,6 +146,7 @@ flowchart LR | --- | --- | | `timer` | `@cordisjs/plugin-timer` | | `hmr` | `@cordisjs/plugin-hmr` | +| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` | | `llm` | `@deepseek-ai/dsh-llm` | | `session` | `@deepseek-ai/dsh-session` | | `session-title` | `@deepseek-ai/dsh-session-title` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index e88cbdb39d..61d149ad83 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -22,6 +22,13 @@ config: root: ['.'] +# `$DSH_HOME/config.yaml` replaces this row's config to select exact GitHub +# repository Plugin generations. The app registers the DSH-owned runtime even +# when the list is empty so a later personal-config edit can load +# transactionally; one-shot headless runs consume the startup value only. +- id: repository-plugins + name: '@deepseek-ai/dsh-repository-plugin' + - id: llm name: '@deepseek-ai/dsh-llm' diff --git a/apps/cli/package.json b/apps/cli/package.json index 2269ceaffb..e6d853db37 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -77,6 +77,7 @@ "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 7b182b4073..eaa1902eff 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -1,6 +1,6 @@ /** * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share - * for the Web/headless surface. + * (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly). * Everything here is what must exist before the Loader runs: the patch * composition over the shipped base and surface overlay (profile json + CLI * flags + the resolved frontend dist), and the fail-loud activation audit after the tree @@ -16,7 +16,13 @@ import { join, resolve } from 'node:path' import { Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' -import { boot, installFailLoud, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot' +import { + boot, + installFailLoud, + loadOverlayPatches, + loadPersonalPatches, + watchPersonalPatches, +} from '@deepseek-ai/dsh-app-boot' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -140,8 +146,10 @@ export interface AppCLIEntryOptions { * `$DSH_HOME/config.yaml` overlay is applied instead. */ extraOverlayPath?: string - /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */ + /** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */ dev: boolean + /** Whether `$DSH_HOME/config.yaml` remains live after the initial boot. */ + watchPersonalConfig: boolean /** --host when explicitly passed; undefined keeps the yml engineering default. */ host?: string /** @@ -235,11 +243,12 @@ export class AppCLIEntry { // user config. Workspace knowledge stays here. put('webserver', 'distIndex', this.resolveDistIndex()) - this.patches = [...overrides.entries()].map(([id, bag]) => { + const generated = [...overrides.entries()].map(([id, bag]) => { const yml = rows.get(id) if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`) return { id, config: { ...(yml.config ?? {}) as Record, ...bag } } }) + this.patches = generated // Telemetry opt-out: a row can only be turned off at the patch layer // (config cannot disable an entry), and the switch must hold BEFORE the @@ -254,17 +263,30 @@ export class AppCLIEntry { // list: patches never cross an include boundary, so nesting them would // silently stop reaching base rows. The surface overlay applies first, then // this entry's profile-json and CLI-flag patches, which therefore win. - const patches = [ + const compose = (overlay: PatchOptions[]): PatchOptions[] => [ ...loadOverlayPatches('dsh', this.options.overlayPath), - ...this.options.extraOverlayPath === undefined - ? loadPersonalPatches('dsh') ?? [] - : loadOverlayPatches('dsh', this.options.extraOverlayPath), + ...overlay, ...this.patches, ] + // An explicit --config overlay REPLACES the personal overlay, so there is + // then no personal layer to keep live — the watcher is personal-only. + const watchPersonal = this.options.watchPersonalConfig && this.options.extraOverlayPath === undefined + const patches = compose( + this.options.extraOverlayPath === undefined + ? loadPersonalPatches('dsh') ?? [] + : loadOverlayPatches('dsh', this.options.extraOverlayPath), + ) this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => { await this.options.prepare?.(ctx) + // Config-only HMR for the personal overlay: module reload stays off for + // this surface (web.cordis.yml disables the shared `hmr` row until its + // reload lifecycle is tested), so this row watches no module roots. + if (watchPersonal) await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) }) + if (watchPersonal) { + await watchPersonalPatches(this.ctx, { binName: 'dsh', compose }) + } } /** Install the diagnostic for plugin rejections that happen after settled boot. */ diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 3ec2792e8e..e41bc03c6c 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -78,6 +78,7 @@ export async function runHeadless(task: string): Promise { configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)), overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)), dev: false, + watchPersonalConfig: false, port: 0, }) const { ctx, port } = await entry.run() diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 93903b2fe6..32b767cb2c 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -28,8 +28,10 @@ import { loadOverlayPatches, loadPersonalPatches, resolveConfigPath, + watchPersonalPatches, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { PatchOptions } from '@cordisjs/plugin-include' import { SessionId } from '@deepseek-ai/dsh-session' import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts' import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite' @@ -201,15 +203,16 @@ export async function runTui( // presence is checked against the tree actually booting, so a // --config-replace tree is judged on its own rows, not the shipped base's. const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig)) - const patches = [ + const composePatches = (personalPatches: PatchOptions[]): PatchOptions[] => [ ...replaceTree ? [] : [ ...loadOverlayPatches(NAME, TUI_OVERLAY), ...resolvedConfig === undefined - ? loadPersonalPatches(NAME) ?? [] + ? personalPatches : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), ], ...telemetryPatch === undefined ? [] : [telemetryPatch], ] + const patches = composePatches(loadPersonalPatches(NAME) ?? []) const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB) const ctx = await boot( NAME, @@ -246,6 +249,14 @@ export async function runTui( } }, ) + // The shipped tree includes HMR and keeps personal config live. An explicit + // --config tree replaces the personal overlay (so there is nothing to keep + // live), and a --config-replace or HMR-less tree remains a valid composition + // that still receives the startup overlay but deliberately has no hidden + // watcher. + if (resolvedConfig === undefined && !replaceTree && ctx.get('hmr') !== undefined) { + await watchPersonalPatches(ctx, { binName: NAME, compose: composePatches }) + } app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) if (showFirstRunWelcome) { diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 815a53a11d..4fbfba4d8d 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -109,6 +109,7 @@ export async function runWeb( ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, dev, prepare: (ctx) => { prepareWebRuntimeContext(ctx, SOURCE_ROOT, mode) }, + watchPersonalConfig: true, ...host !== undefined && { host }, ...port !== undefined && { port }, ...workspaceRoot !== undefined && { workspaceRoot }, diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 77e65e7d26..3cdf02e3ef 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -1,4 +1,5 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' +import { createHash } from 'node:crypto' import { realpathSync } from 'node:fs' import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -640,6 +641,61 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) + it('loads a cached repository Plugin from personal config alone', async () => { + const source = 'github:fixture/repository#fixed-ref' + const specifier = `${source}&path:/.dsh-plugin` + const key = createHash('sha256').update(specifier).digest('hex') + const packageRoot = `cache/repository-plugins/${key}/node_modules/repository` + const manifest = { name: 'config-only-fixture', skills: ['dsh-plugin-assets/skills/0'] } + const wrapper = [ + '// Generated by dsh-plugin-prepare. Do not edit.', + `const manifest = ${JSON.stringify(manifest)}`, + `export const name = ${JSON.stringify(manifest.name)}`, + "export const inject = ['loader']", + 'export async function apply(ctx) {', + " const runtime = ctx.loader.builtins['dsh-repository-plugin']", + " if (runtime === undefined) throw new Error('missing Cordis builtin dsh-repository-plugin')", + ' await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })', + '}', + '', + ].join('\n') + const output = await smoke({ + label: 'dsh personal repository Plugin', + tempDirPrefix: 'dsh-personal-repository-plugin-', + binScript: dshBinScript, + configArgs: [], + prepare: seedWorkspace({ + personal: { + 'config.yaml': [ + '- id: repository-plugins', + " name: '@deepseek-ai/dsh-repository-plugin'", + ' config:', + ' repositories:', + ` - '${source}'`, + '', + ].join('\n'), + [`cache/repository-plugins/${key}/.repository-cache.json`]: `${JSON.stringify({ specifier })}\n`, + [`${packageRoot}/dsh-plugin.mjs`]: wrapper, + [`${packageRoot}/dsh-plugin-assets/skills/0/config-only-repository/SKILL.md`]: [ + '---', + 'name: config-only-repository', + 'description: CONFIG_ONLY_REPOSITORY_SKILL', + '---', + '', + 'Repository instructions.', + '', + ].join('\n'), + }, + }), + actions: [ + { waitFor: 'main-session-', send: '/skill:config-only' }, + { waitFor: 'CONFIG_ONLY_REPOSITORY_SKILL', send: '\x03/exit\r' }, + ], + }) + expect(output).toContain('CONFIG_ONLY_REPOSITORY_SKILL') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('fails loud instead of booting when the personal config.yaml is invalid', async () => { const output = await smoke({ label: 'dsh invalid personal config', diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 14ce334f48..ed6b82ec83 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -995,6 +995,22 @@ export interface Config { Source: [`packages/guard/repeat-tool-guard/src/index.ts:28`](../packages/guard/repeat-tool-guard/src/index.ts) +## `@deepseek-ai/dsh-repository-plugin` + +Requires: `loader` + +```ts config-catalog +/** Repository Plugin runtime and source-list configuration. */ +export interface Config { + /** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */ + repositories?: string[] + /** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */ + cacheDir?: string +} +``` + +Source: [`packages/cordis/repository-plugin/src/index.ts:41`](../packages/cordis/repository-plugin/src/index.ts) + ## `@deepseek-ai/dsh-sandbox-local` ```ts config-catalog @@ -2322,7 +2338,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) -- `@deepseek-ai/dsh-repository-plugin` — requires `loader` ([`packages/cordis/repository-plugin/src/index.ts`](../packages/cordis/repository-plugin/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 9c3afcec85..5ba907f02f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -907,6 +907,7 @@ flowchart TD pkg_tool_subagent --> pkg_tools pkg_repository_plugin --> pkg_invariants pkg_repository_plugin --> pkg_mcp_client + pkg_repository_plugin --> pkg_paths pkg_repository_plugin --> pkg_skill_local pkg_hooks_claude --> pkg_agent pkg_hooks_claude --> pkg_hook_protocol @@ -1212,7 +1213,7 @@ flowchart TD | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`skill-local`](../packages/skill/skill-local) | +| [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/knip.json b/knip.json index 99b152738d..43102b0b0d 100644 --- a/knip.json +++ b/knip.json @@ -53,8 +53,7 @@ "**/*.ts" ], "ignoreDependencies": [ - "@deepseek-ai/.+", - "@cordisjs/.+" + "@deepseek-ai/.+" ] }, "packages/util/home": { @@ -631,8 +630,7 @@ "tests/**/*.ts" ], "ignoreDependencies": [ - "@deepseek-ai/.+", - "@cordisjs/.+" + "@deepseek-ai/.+" ] }, "packages/client/modules": { diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml index 52f506c37c..8cd641781f 100644 --- a/packages/cordis/repository-plugin/README.i18n.yaml +++ b/packages/cordis/repository-plugin/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/cordis/repository-plugin/README.md -README.md: 80744eb489d1714f59ba6e53207476a8ce222e24 -README.zh.md: d297b44e4a065fa99865e3a42d2c823c7b7c5848 +README.md: 0ba1ce86d99a12e0f94e7a39fd3ae44dc29889a7 +README.zh.md: 2d9544166eafbb1066b65031969925890f2b9797 diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md index 80744eb489..0ba1ce86d9 100644 --- a/packages/cordis/repository-plugin/README.md +++ b/packages/cordis/repository-plugin/README.md @@ -28,6 +28,23 @@ Place an ordinary package in the repository's `.dsh-plugin` directory: `dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory. +## Standalone app configuration + +The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plugins` row. A standalone user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`): + +```yaml +- id: repository-plugins + name: '@deepseek-ai/dsh-repository-plugin' + config: + repositories: + - 'github:PolyArch/humanize#' + - 'github:owner/repository#&path:/plugins/one/.dsh-plugin' +``` + +Each source must use `github:owner/repository#`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root. + +The TUI and Web watch `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless runs consume the file only at startup. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md). + ## Preparation `dsh-plugin-prepare` validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point. diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md index d297b44e4a..2d9544166e 100644 --- a/packages/cordis/repository-plugin/README.zh.md +++ b/packages/cordis/repository-plugin/README.zh.md @@ -28,6 +28,23 @@ `dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` package。 +## 独立应用配置 + +已交付的 `dsh` TUI、Web 和无头配置树包含一个空的 `repository-plugins` 配置项。独立用户只需在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,即可启用精确指定的 GitHub generation: + +```yaml +- id: repository-plugins + name: '@deepseek-ai/dsh-repository-plugin' + config: + repositories: + - 'github:PolyArch/humanize#' + - 'github:owner/repository#&path:/plugins/one/.dsh-plugin' +``` + +每个源都必须采用 `github:owner/repository#`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为显式配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。 + +TUI 和 Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套仓库插件 generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头运行只在启动时使用该文件。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入仓库插件的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 + ## 准备阶段 `dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。 diff --git a/packages/cordis/repository-plugin/package.json b/packages/cordis/repository-plugin/package.json index f6500eb007..07bfa924c2 100644 --- a/packages/cordis/repository-plugin/package.json +++ b/packages/cordis/repository-plugin/package.json @@ -34,6 +34,7 @@ "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-mcp-client": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-skill-local": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -44,6 +45,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/cordis/repository-plugin/src/index.ts b/packages/cordis/repository-plugin/src/index.ts index 26922654bb..73eb2dc473 100644 --- a/packages/cordis/repository-plugin/src/index.ts +++ b/packages/cordis/repository-plugin/src/index.ts @@ -8,8 +8,10 @@ import { dirname, isAbsolute, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' import type {} from '@cordisjs/plugin-loader' +import { RepositoryCache } from '@cordisjs/plugin-loader/repository' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as McpClient from '@deepseek-ai/dsh-mcp-client' +import { z } from 'zod' import { REPOSITORY_PLUGIN_BUILTIN, isOutside, @@ -17,6 +19,11 @@ import { type PreparedPluginConfig, } from './format.ts' import { parseMcpDocument, resolveMcpServers } from './mcp.ts' +import { + loadPreparedRepository, + resolveRepositoryCacheDirectory, + resolveRepositorySpecifier, +} from './source.ts' export { PREPARED_ASSET_DIRECTORY, @@ -31,6 +38,19 @@ export const name = 'repository-plugin' /** Loader service required to register the fixed prepared-wrapper builtin. */ export const inject = ['loader'] +/** Repository Plugin runtime and source-list configuration. */ +export interface Config { + /** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */ + repositories?: string[] + /** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */ + cacheDir?: string +} + +export const Config = z.object({ + repositories: z.array(z.string().min(1)).default([]), + cacheDir: z.string().min(1).optional(), +}).strict().default({ repositories: [] }) + function preparedPath(baseUrl: string, configured: string): string { if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`) const directory = dirname(fileURLToPath(baseUrl)) @@ -101,16 +121,25 @@ const preparedRuntime = { * Register the DSH-owned runtime as the Loader builtin used by fixed prepared wrappers. * @param ctx - plugin context carrying the Loader service. */ -export function apply(ctx: Context): void { +export async function apply(ctx: Context, config: Config = {}): Promise { if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) { throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`) } - ctx.effect(function* () { + const repositories = (config.repositories ?? []).map(resolveRepositorySpecifier) + if (new Set(repositories).size !== repositories.length) { + throw new Error('repository sources must resolve to unique exact specifiers') + } + const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir)) + await ctx.effect(async function* () { ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime yield () => { if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) { Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN) } } - }, 'repository-plugin Loader builtin') + for (const repository of repositories) { + const plugin = await loadPreparedRepository(ctx, cache, repository) + yield plugin.dispose + } + }, 'repository-plugin runtime and sources') } diff --git a/packages/cordis/repository-plugin/src/source.ts b/packages/cordis/repository-plugin/src/source.ts new file mode 100644 index 0000000000..c0362bf162 --- /dev/null +++ b/packages/cordis/repository-plugin/src/source.ts @@ -0,0 +1,74 @@ +/** + * GitHub repository source validation and prepared-wrapper loading. + * @module + */ + +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { Context, Fiber, Plugin } from 'cordis' +import type { RepositoryCache } from '@cordisjs/plugin-loader/repository' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { PREPARED_ENTRY_FILENAME } from './format.ts' + +/** Directory under the Harness home containing immutable repository generations. */ +export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins' + +const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s&]+)(?:&path:(\/[^\s&]+))?$/ + +function validPluginPath(path: string): boolean { + const segments = path.split('/').slice(1) + return segments.length > 0 + && segments.at(-1) === '.dsh-plugin' + && segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..') +} + +/** + * Normalize one user-facing GitHub source to the exact pnpm dependency specifier. + * @param configured - `github:owner/repo#ref` with an optional `&path:/.../.dsh-plugin`. + * @returns the exact specifier, with the root `.dsh-plugin` subpath added when omitted. + * @throws when the GitHub owner, repository, explicit ref, or plugin subpath is invalid. + */ +export function resolveRepositorySpecifier(configured: string): string { + const match = GITHUB_SOURCE_PATTERN.exec(configured) + if (match === null) { + throw new Error(`repository source must use github:owner/repo# with an optional &path:/.../.dsh-plugin: ${JSON.stringify(configured)}`) + } + const path = match[4] + if (path !== undefined && !validPluginPath(path)) { + throw new Error(`repository source path must be an absolute repository subpath ending in .dsh-plugin without empty, . or .. segments: ${JSON.stringify(path)}`) + } + return path === undefined ? `${configured}&path:/.dsh-plugin` : configured +} + +/** + * Resolve the persistent repository cache root. + * @param configured - explicit cache directory, or undefined for `$DSH_HOME/cache/repository-plugins`. + * @returns an absolute cache directory. + */ +export function resolveRepositoryCacheDirectory(configured: string | undefined): string { + return resolve(configured ?? join(resolveDshHome(), 'cache', DEFAULT_REPOSITORY_CACHE_DIRECTORY)) +} + +/** + * Load one exact repository generation's generated wrapper as a child Cordis fiber. + * @param ctx - repository runtime context that owns the child. + * @param cache - package-manager-native immutable repository cache. + * @param specifier - normalized exact pnpm dependency specifier. + * @returns the settled prepared-wrapper fiber. + * @throws when installation, wrapper import, manifest validation, or child registration fails. + */ +export async function loadPreparedRepository( + ctx: Context, + cache: Pick, + specifier: string, +): Promise { + const directory = await cache.resolve(specifier) + const filename = join(directory, PREPARED_ENTRY_FILENAME) + try { + const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin + const fiber = ctx.plugin(plugin) + return await fiber + } catch (cause) { + throw new Error(`failed to load prepared repository Plugin ${JSON.stringify(specifier)} from ${filename}`, { cause }) + } +} diff --git a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts index e26f267e72..06d0c9d322 100644 --- a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts @@ -2,9 +2,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, relative, resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import { RepositoryCache } from '@cordisjs/plugin-loader/repository' import SkillService from '@deepseek-ai/dsh-skill' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -12,6 +13,11 @@ import InvariantService from '@deepseek-ai/dsh-invariants' import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin' import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant' import { parsePreparedPluginConfig } from '../src/format.ts' +import { + loadPreparedRepository, + resolveRepositoryCacheDirectory, + resolveRepositorySpecifier, +} from '../src/source.ts' const roots: string[] = [] @@ -35,6 +41,8 @@ async function writeSkill(root: string, name: string): Promise { } afterEach(async () => { + vi.restoreAllMocks() + vi.unstubAllEnvs() await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) @@ -253,7 +261,7 @@ describe('prepared repository plugin Loader composition', () => { await ctx.plugin(Loader) const registrar = ctx.plugin(RepositoryPlugin) await registrar - expect(() => { RepositoryPlugin.apply(ctx) }).toThrow('already registered') + await expect(RepositoryPlugin.apply(ctx)).rejects.toThrow('already registered') const replacement = { name: 'replacement', apply() {} } ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement @@ -263,6 +271,113 @@ describe('prepared repository plugin Loader composition', () => { }) }) +describe('configured GitHub repository sources', () => { + it('defaults an omitted source list and rejects unknown configuration fields', () => { + expect(RepositoryPlugin.Config.parse(undefined)).toEqual({ repositories: [] }) + expect(RepositoryPlugin.Config.safeParse({ repositories: [], unexpected: true }).success).toBe(false) + }) + + it('accepts an empty direct-apply config', async () => { + const ctx = new Context() + await ctx.plugin(Loader) + await RepositoryPlugin.apply(ctx, {}) + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined() + await ctx.fiber.dispose() + }) + + it('adds the root plugin subpath and preserves an explicit nested plugin subpath', () => { + expect(resolveRepositorySpecifier('github:PolyArch/humanize#v1.0.0')) + .toBe('github:PolyArch/humanize#v1.0.0&path:/.dsh-plugin') + expect(resolveRepositorySpecifier('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin')) + .toBe('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin') + }) + + it('rejects absent refs and invalid plugin subpaths', () => { + for (const source of [ + 'github:owner/repository', + 'github:owner/repository#', + 'https://github.com/owner/repository#ref', + 'github:owner/repository#ref&path:relative/.dsh-plugin', + ]) { + expect(() => resolveRepositorySpecifier(source)).toThrow('must use github:owner/repo#') + } + for (const path of [ + '/plugins//.dsh-plugin', + '/plugins/../.dsh-plugin', + '/plugins/./.dsh-plugin', + '/plugins/not-a-plugin', + ]) { + expect(() => resolveRepositorySpecifier(`github:owner/repository#ref&path:${path}`)) + .toThrow('path must be an absolute repository subpath') + } + }) + + it('resolves the default cache under DSH_HOME and an explicit cache absolutely', async () => { + const root = await temporaryDirectory('cache-root') + vi.stubEnv('DSH_HOME', root) + expect(resolveRepositoryCacheDirectory(undefined)).toBe(join(root, 'cache', 'repository-plugins')) + expect(resolveRepositoryCacheDirectory(join(root, 'explicit'))).toBe(join(root, 'explicit')) + }) + + it('loads a configured source through the immutable cache and removes its skill on teardown', async () => { + const root = await temporaryDirectory('configured-source') + await writeSkill(join(root, 'skills'), 'configured-repository-skill') + const directory = await writePlugin(root, 'configured-source-fixture', { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + const resolved: string[] = [] + const cacheDirectory = join(root, 'cache') + vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async function (this: RepositoryCache, specifier) { + expect(this.directory).toBe(cacheDirectory) + resolved.push(specifier) + return directory + }) + + const ctx = new Context() + await ctx.plugin(Loader) + await ctx.plugin(SkillService) + const registrar = ctx.plugin(RepositoryPlugin, { + repositories: ['github:owner/repository#fixed-ref'], + cacheDir: cacheDirectory, + }) + await registrar + expect(resolved).toEqual(['github:owner/repository#fixed-ref&path:/.dsh-plugin']) + await expect(ctx.skills.get('configured-repository-skill')).resolves.toMatchObject({ + provider: 'repository:configured-source-fixture', + }) + + await registrar.dispose() + await expect(ctx.skills.get('configured-repository-skill')).resolves.toBeUndefined() + await ctx.fiber.dispose() + }) + + it('rejects duplicate generations and cleans the builtin after cache preparation fails', async () => { + const ctx = new Context() + await ctx.plugin(Loader) + await expect(RepositoryPlugin.apply(ctx, { + repositories: [ + 'github:owner/repository#ref', + 'github:owner/repository#ref', + ], + })).rejects.toThrow('must resolve to unique exact specifiers') + + vi.spyOn(RepositoryCache.prototype, 'resolve').mockRejectedValue(new Error('prepare failed')) + await expect(RepositoryPlugin.apply(ctx, { + repositories: ['github:owner/repository#other'], + })).rejects.toThrow('prepare failed') + expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('labels a missing prepared wrapper with its exact source and path', async () => { + const root = await temporaryDirectory('missing-wrapper') + const ctx = new Context() + const specifier = 'github:owner/repository#missing&path:/.dsh-plugin' + await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier)) + .rejects.toThrow(`failed to load prepared repository Plugin ${JSON.stringify(specifier)}`) + await ctx.fiber.dispose() + }) +}) + describe('repository plugin invariant companion', () => { it('registers its explained empty invariant', async () => { const ctx = new Context() diff --git a/packages/cordis/repository-plugin/tsconfig.json b/packages/cordis/repository-plugin/tsconfig.json index f7918dcdd9..67cb0dedf2 100644 --- a/packages/cordis/repository-plugin/tsconfig.json +++ b/packages/cordis/repository-plugin/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../mcp/mcp-client" }, + { + "path": "../../util/paths" + }, { "path": "../../support/invariants" } diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 6d7aa6f0d3..65a471f69a 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: e82d378f9cabd24d0f8b3069237f142c1885191f -README.zh.md: 5d749531e48a502291491e6d047b45cd8a505544 +README.md: 2eb9e904d574df39b0884558fc0a53f9dc04cdc1 +README.zh.md: 78bd99943fcadebf42a5d772d49f3bfcf6a8790a diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index e82d378f9c..2eb9e904d5 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -13,6 +13,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by personal-config HMR | +| `watchPersonalPatches(ctx, options)` | Register `$DSH_HOME/config.yaml` with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current personal overlay) and returns an async disposer | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | @@ -26,11 +28,13 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Personal config -A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: +A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI, Web, and headless surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: - **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. - **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. +The TUI and Web keep `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. + Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures. ## Model Experience diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 5d749531e4..78bd99943f 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -13,6 +13,8 @@ | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留个人配置 HMR(热模块替换)使用的确切根配置项 | +| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册 `$DSH_HOME/config.yaml`;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前个人 overlay)以事务方式重新组合完整 patch 列表,并返回异步 disposer | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | @@ -26,11 +28,13 @@ Loader 结算会在导入或生命周期失败时 reject,并携带失败的配 ## 个人配置 -开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: +开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI、Web 和无头界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: - **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 - **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 +TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 + 子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。 ## 模型体验 diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index 68062cb480..18a42a27a1 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -30,6 +30,7 @@ "js-yaml": "^4.2.0" }, "peerDependencies": { + "@cordisjs/plugin-hmr": "^1.0.15", "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-invariants": "^0.0.1", @@ -37,6 +38,11 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@cordisjs/plugin-hmr": { + "optional": true + } + }, "devDependencies": { "@cordisjs/plugin-hmr": "workspace:^", "@cordisjs/plugin-include": "workspace:^", diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 7897c5fb89..26747ef545 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -11,9 +11,10 @@ import { readFileSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' -import Loader, { type EntryOptions } from '@cordisjs/plugin-loader' +import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type {} from '@cordisjs/plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -67,6 +68,8 @@ export function loadEnv( /** File inside the Harness home holding the personal loader overlay patches. */ export const PERSONAL_CONFIG_FILENAME = 'config.yaml' +const bootstrapIncludes = new WeakMap() + // The include's YAML dialect (`!!js` scalars become expression nodes the // Loader interpolates against each entry's context at mount time), imported // from the include itself so patch parsing and config dumping can never drift @@ -287,6 +290,86 @@ function groupedDump( return lines.join('\n') + '\n' } +/** Options for live personal-config reconciliation. */ +export interface PersonalPatchWatchOptions { + /** Diagnostic prefix used by {@link loadPersonalPatches}. */ + binName: string + /** Harness home containing `config.yaml`; defaults to {@link resolveDshHome}. */ + dir?: string + /** + * Compose the full patch list for a fresh personal-overlay generation — + * the same composition the app booted with, so a reload can interleave the + * new personal patches between app-owned layers (surface overlay below, + * profile/flag patches above). Identity when omitted: the personal overlay + * is the whole patch list. + */ + compose?: (personalPatches: PatchOptions[]) => PatchOptions[] +} + +/** + * Watch the personal overlay through Cordis HMR and transactionally reapply it to the boot include. + * @param ctx - settled app context containing the root Include and an active HMR service. + * @param options - diagnostic, Harness-home, and patch-composition inputs. + * @returns an asynchronous disposer after the exact-path watcher is ready. + * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails. + */ +export async function watchPersonalPatches( + ctx: Context, + options: PersonalPatchWatchOptions, +): Promise<() => Promise> { + const { binName, dir = resolveDshHome(), compose = (patches: PatchOptions[]) => patches } = options + const hmr = ctx.get('hmr') + if (hmr === undefined) throw new Error(`${binName}: personal config watching requires the Cordis HMR service`) + const entry = bootstrapIncludes.get(ctx) + if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`) + const filename = join(dir, PERSONAL_CONFIG_FILENAME) + const { patches: _initialPatches, ...includeConfig } = entry.options.config as Include.Config + return hmr.registerConfig(filename, async () => { + const personalPatches = loadPersonalPatches(binName, dir) ?? [] + const patches = compose(personalPatches) + await entry.update({ + config: { + ...includeConfig, + patches, + }, + }) + }) +} + +/** + * Mount and remember the exact root Include entry used by app boot and personal-config HMR. + * @param ctx - context carrying an initialized Loader service. + * @param absoluteConfigPath - absolute YAML or JSON configuration path. + * @param patches - initial app and personal patches, applied in order. + * @returns the created root Include entry, or `undefined` when a surface + * disposed the whole tree (taking the Loader service with it) while the + * transactional create was still settling entry lifecycle. + */ +export async function mountRootInclude( + ctx: Context, + absoluteConfigPath: string, + patches: readonly PatchOptions[] = [], +): Promise { + ctx.loader.builtins.include = Include + // Pinned id: the bootstrap include is app glue, not a config row, and its + // id appears in Loader failure chains — a random id would make startup + // diagnostics unstable across runs (and snapshot fixtures). + const rootInclude: EntryOptions = { + id: 'include', + name: 'cordis:include', + config: { + path: pathToFileURL(absoluteConfigPath).href, + ...patches.length > 0 ? { patches: [...patches] } : {}, + }, + } + const includeId = await ctx.loader.create(rootInclude) + const loader = ctx.get('loader') + if (loader === undefined) return undefined + const entry = loader.resolve(includeId) + bootstrapIncludes.set(ctx, entry) + return entry +} + /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. @@ -463,21 +546,9 @@ export async function boot( ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' ctx.provide('dshHomePath', dshHomePath) await ctx.plugin(Loader) - ctx.loader.builtins.include = Include await prepare?.(ctx) stage = 'plugin tree failed to load' - // Pinned id: the bootstrap include is app glue, not a config row, and its - // id appears in Loader failure chains — a random id would make startup - // diagnostics unstable across runs (and snapshot fixtures). - const rootInclude: EntryOptions = { - id: 'include', - name: 'cordis:include', - config: { - path: pathToFileURL(absoluteConfigPath).href, - ...patches !== undefined && patches.length > 0 ? { patches } : {}, - }, - } - await ctx.loader.create(rootInclude) + await mountRootInclude(ctx, absoluteConfigPath, patches) // A surface can finish and dispose the whole tree while startup is still // in flight: the TUI renders as soon as its own fiber starts, so an `/exit` // typed before the last entry settles tears the context down under us. The diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/personal-config.spec.ts index 5d72238cfa..62003599d8 100644 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ b/packages/ui/app-boot/tests/personal-config.spec.ts @@ -4,21 +4,36 @@ * a real Loader tree. */ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import { Context } from 'cordis' +import Hmr from '@cordisjs/plugin-hmr' +import Loader from '@cordisjs/plugin-loader' +import Timer from '@cordisjs/plugin-timer' import { boot, loadPersonalPatches, PERSONAL_CONFIG_FILENAME, + watchPersonalPatches, } from '../src/index.ts' const NAME = 'dsh-test-bin' const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-')) +async function eventually(test: () => boolean, message: string): Promise { + const deadline = Date.now() + 10_000 + while (!test()) { + if (Date.now() >= deadline) throw new Error(message) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +const settleChokidarChangeThrottle = (): Promise => new Promise(resolve => setTimeout(resolve, 75)) + describe('loadPersonalPatches', () => { afterEach(() => { delete process.env.DSH_HOME @@ -86,7 +101,13 @@ describe('loadPersonalPatches', () => { describe('boot with personal patches', () => { function writeTree(dir: string): string { - writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') + writeFileSync(join(dir, 'noop.mjs'), [ + 'export const name = "noop"', + 'export function apply(_ctx, config = {}) {', + ' if (config.fail) throw new Error("candidate config failed")', + '}', + '', + ].join('\n')) writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') return join(dir, 'cordis.yml') } @@ -138,4 +159,78 @@ describe('boot with personal patches', () => { await ctxEmpty.fiber.dispose() } }) + + it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => { + const dir = tmp() + const personal = tmp() + const filename = join(personal, PERSONAL_CONFIG_FILENAME) + const basePatches = [{ id: 'noop', config: { value: 'generated' } }] + const ctx = await boot(NAME, writeTree(dir), basePatches) + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + const failures: Array<{ filename: string; error: Error }> = [] + ctx.on('hmr/config-update-failed', (failedFilename, error) => { + failures.push({ filename: failedFilename, error }) + }) + const dispose = await watchPersonalPatches(ctx, { + binName: NAME, + dir: personal, + compose: personalPatches => [...basePatches, ...personalPatches], + }) + try { + writeFileSync(filename, '- id: noop\n config:\n value: live\n') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied') + + writeFileSync(filename, '- id: noop\n config:\n fail: true\n') + await eventually(() => failures.length === 1, 'failed candidate was not broadcast') + expect(failures[0]).toMatchObject({ filename }) + expect(failures[0]?.error).toBeInstanceOf(Error) + expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') + await settleChokidarChangeThrottle() + + writeFileSync(filename, 'invalid: [unclosed\n') + await eventually(() => failures.length === 2, 'parse failure was not broadcast') + expect(failures[1]?.error).toBeInstanceOf(Error) + expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') + await settleChokidarChangeThrottle() + + writeFileSync(filename, '- id: noop\n config:\n value: recovered\n') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied') + await settleChokidarChangeThrottle() + + unlinkSync(filename) + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch') + expect(failures).toHaveLength(2) + await settleChokidarChangeThrottle() + + // Default compose: the personal overlay IS the whole patch list, so a + // fresh generation replaces the app-owned layer instead of stacking on it. + await dispose() + const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) + try { + writeFileSync(filename, '- id: noop\n config:\n value: identity\n') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied') + } finally { + await disposeDefault() + } + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + + it('fails loud when the exact watcher lacks HMR or a root Include', async () => { + const dir = tmp() + const withoutHmr = await boot(NAME, writeTree(dir)) + await expect(watchPersonalPatches(withoutHmr, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the Cordis HMR service') + await withoutHmr.fiber.dispose() + + const withoutInclude = new Context() + withoutInclude.baseUrl = pathToFileURL(`${tmp()}/`).href + await withoutInclude.plugin(Loader) + await withoutInclude.plugin(Timer) + await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry') + await withoutInclude.fiber.dispose() + }) }) diff --git a/packages/ui/app-boot/tests/repository-cache.spec.ts b/packages/ui/app-boot/tests/repository-cache.spec.ts index 33a223020f..b7d80470b8 100644 --- a/packages/ui/app-boot/tests/repository-cache.spec.ts +++ b/packages/ui/app-boot/tests/repository-cache.spec.ts @@ -112,17 +112,27 @@ describe('RepositoryCache', () => { await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid') }) - it('runs a Git dependency prepare script through the bundled pnpm', { timeout: 60_000 }, async () => { + it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => { const root = await temporaryRoot('repository-pnpm') const repository = join(root, 'source') - await mkdir(repository) + await mkdir(join(repository, '.dsh-plugin'), { recursive: true }) + await mkdir(join(repository, 'skills', 'fixture'), { recursive: true }) await writeFile(join(repository, 'package.json'), `${JSON.stringify({ name: 'repository-fixture', version: '1.0.0', - scripts: { prepare: 'node prepare.mjs' }, })}\n`) - await writeFile(join(repository, 'prepare.mjs'), [ - "import { writeFile } from 'node:fs/promises'", + await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n') + await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({ + name: 'repository-plugin-fixture', + version: '1.0.0', + scripts: { prepare: 'node prepare.mjs' }, + dsh: { skills: ['../skills'] }, + })}\n`) + await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [ + "import { cp, mkdir, writeFile } from 'node:fs/promises'", + "await mkdir('dsh-plugin-assets/skills', { recursive: true })", + "await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", + "await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')", "await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)", '', ].join('\n')) @@ -134,11 +144,16 @@ describe('RepositoryCache', () => { 'commit', '--quiet', '-m', 'fixture', ], { cwd: repository }) const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' }) - const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}` + const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin` vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible') vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden') const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n') + await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') + await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8')) + .resolves.toBe('repository skill source\n') + await expect(readFile(join(installed, 'package.json'), 'utf8')) + .resolves.toContain('repository-plugin-fixture') }) }) diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json index 23f83dda51..beb61317dc 100644 --- a/packages/ui/app-boot/tsconfig.json +++ b/packages/ui/app-boot/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/include" }, + { + "path": "../../../vendor/hmr" + }, { "path": "../../support/invariants" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4eb0aedbfb..7ec61249d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,9 @@ importers: '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard + '@deepseek-ai/dsh-repository-plugin': + specifier: workspace:^ + version: link:../../packages/cordis/repository-plugin '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../packages/sandbox/sandbox-local @@ -2368,6 +2371,9 @@ importers: '@deepseek-ai/dsh-mcp-client': specifier: workspace:^ version: link:../../mcp/mcp-client + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill From b6284c84672596e6268e471feb9b0c85788d2ead Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:26:47 +0800 Subject: [PATCH 020/114] fix(review): audit prepared-wrapper activation and pin generated shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot round 1 on the DSH-home integration: - generated wrappers now inject the services their manifest needs (skills/ tools beside loader), and loadPreparedRepository rejects a wrapper fiber that settles anything but ACTIVE — a composition missing a required service fails the repository transaction instead of committing an ACTIVE row over a silently PENDING child (critical finding) - the github: source ref segment excludes '#', so 'a#b' refs fail at the config parser with the promised syntax instead of inside pnpm - watchPersonalPatches re-reads the include's non-patch options per refresh instead of a registration-time snapshot - the TUI smoke's cache-seeded wrapper is produced by the real prepareDshPlugin (cache LAYOUT stays a deliberate external pin) - new Loader integration test drives a live repositories update through entry.update: generation swap, old skills removed, failed candidate rolled back to the previous generation --- apps/cli/tests/tui-keyless-smoke.e2e.ts | 52 ++++++++++---- docs/config-catalog.md | 2 +- .../cordis/repository-plugin/src/source.ts | 24 ++++++- .../tests/repository-plugin.spec.ts | 69 +++++++++++++++++++ packages/ui/app-boot/src/index.ts | 5 +- 5 files changed, 135 insertions(+), 17 deletions(-) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 3cdf02e3ef..f61d37360b 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -7,6 +7,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' +import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin' import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts' import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' @@ -67,6 +68,38 @@ function seedWorkspace( } } +/** + * Run the real `prepareDshPlugin` over an equivalent one-skill `.dsh-plugin` + * package and return the generated wrapper text, so the smoke's cache-seeded + * wrapper can never drift from the generator's template. + */ +async function generatePreparedWrapper(pluginName: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-smoke-wrapper-')) + try { + const plugin = join(root, '.dsh-plugin') + await mkdir(join(root, 'skills', 'config-only-repository'), { recursive: true }) + await writeFile(join(root, 'skills', 'config-only-repository', 'SKILL.md'), [ + '---', + 'name: config-only-repository', + 'description: Generator input; the seeded cache copy owns the visible text.', + '---', + '', + 'Repository instructions.', + '', + ].join('\n')) + await mkdir(plugin, { recursive: true }) + await writeFile(join(plugin, 'package.json'), `${JSON.stringify({ + name: pluginName, + version: '0.0.0', + dsh: { skills: ['../skills'] }, + }, undefined, 2)}\n`) + await prepareDshPlugin(plugin) + return await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8') + } finally { + await rm(root, { recursive: true, force: true }) + } +} + /** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */ async function seedResumeSession(cwd: string): Promise { const sessionCwd = realpathSync.native(cwd) @@ -646,19 +679,12 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { const specifier = `${source}&path:/.dsh-plugin` const key = createHash('sha256').update(specifier).digest('hex') const packageRoot = `cache/repository-plugins/${key}/node_modules/repository` - const manifest = { name: 'config-only-fixture', skills: ['dsh-plugin-assets/skills/0'] } - const wrapper = [ - '// Generated by dsh-plugin-prepare. Do not edit.', - `const manifest = ${JSON.stringify(manifest)}`, - `export const name = ${JSON.stringify(manifest.name)}`, - "export const inject = ['loader']", - 'export async function apply(ctx) {', - " const runtime = ctx.loader.builtins['dsh-repository-plugin']", - " if (runtime === undefined) throw new Error('missing Cordis builtin dsh-repository-plugin')", - ' await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })', - '}', - '', - ].join('\n') + // Produced by the real generator (prepareDshPlugin over an equivalent + // .dsh-plugin package) rather than hand-written, so a wrapper-template + // change cannot leave this smoke exercising a stale shape. The cache + // LAYOUT below (sha256 key, marker, node_modules/repository) remains a + // deliberate external pin of the durable on-disk format. + const wrapper = await generatePreparedWrapper('config-only-fixture') const output = await smoke({ label: 'dsh personal repository Plugin', tempDirPrefix: 'dsh-personal-repository-plugin-', diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ed6b82ec83..ad12c61134 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1009,7 +1009,7 @@ export interface Config { } ``` -Source: [`packages/cordis/repository-plugin/src/index.ts:41`](../packages/cordis/repository-plugin/src/index.ts) +Source: [`packages/cordis/repository-plugin/src/index.ts:42`](../packages/cordis/repository-plugin/src/index.ts) ## `@deepseek-ai/dsh-sandbox-local` diff --git a/packages/cordis/repository-plugin/src/source.ts b/packages/cordis/repository-plugin/src/source.ts index c0362bf162..3ccddc6c52 100644 --- a/packages/cordis/repository-plugin/src/source.ts +++ b/packages/cordis/repository-plugin/src/source.ts @@ -5,15 +5,23 @@ import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import type { Context, Fiber, Plugin } from 'cordis' +import type { Context, Fiber, FiberState, Plugin } from 'cordis' import type { RepositoryCache } from '@cordisjs/plugin-loader/repository' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { PREPARED_ENTRY_FILENAME } from './format.ts' +// Value mirror: Cordis's const enum has no runtime object to import. Keep +// aligned with `packages/cordis/tool-cordis/src/fiber-state.ts`. +const FIBER_ACTIVE = 2 as FiberState.ACTIVE + /** Directory under the Harness home containing immutable repository generations. */ export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins' -const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s&]+)(?:&path:(\/[^\s&]+))?$/ +// The ref segment excludes `#` so `github:o/r#a#b` fails here — at the config +// parser, with the syntax the error message promises — instead of inside the +// cache's pnpm install ('misconfiguration fails loud at the earliest +// resolvable point'). +const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/ function validPluginPath(path: string): boolean { const segments = path.split('/').slice(1) @@ -67,6 +75,18 @@ export async function loadPreparedRepository( try { const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin const fiber = ctx.plugin(plugin) + await fiber + // Awaiting a service-gated fiber returns while it is still PENDING (the + // generated wrapper injects `skills`/`tools` per its manifest). This + // runtime commits the repository configuration transactionally, so a + // composition that never provides a required service must reject the + // transaction here — not settle ACTIVE with a silently pending child. + if (fiber.state !== FIBER_ACTIVE) { + const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) + /* v8 ignore next 2 -- the 'unknown' arm needs a service to appear after the state read; not deterministically stageable. */ + const detail = missing.join(', ') || 'unknown' + throw new Error(`prepared wrapper did not activate (waiting for services: ${detail})`) + } return await fiber } catch (cause) { throw new Error(`failed to load prepared repository Plugin ${JSON.stringify(specifier)} from ${filename}`, { cause }) diff --git a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts index 06d0c9d322..39840fd1f3 100644 --- a/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/cordis/repository-plugin/tests/repository-plugin.spec.ts @@ -296,6 +296,7 @@ describe('configured GitHub repository sources', () => { for (const source of [ 'github:owner/repository', 'github:owner/repository#', + 'github:owner/repository#a#b', 'https://github.com/owner/repository#ref', 'github:owner/repository#ref&path:relative/.dsh-plugin', ]) { @@ -350,6 +351,52 @@ describe('configured GitHub repository sources', () => { await ctx.fiber.dispose() }) + it('swaps generations on a live source-list update and rolls a failed candidate back', async () => { + // The headline flow: a personal-config edit reaches this plugin as a + // Loader entry.update, which restarts the row's fiber (old cleanup, then + // new apply — so the 'already registered' builtin guard must not fire). + const roots: Record = {} + for (const generation of ['one', 'two'] as const) { + const root = await temporaryDirectory(`live-${generation}`) + await writeSkill(join(root, 'skills'), `live-skill-${generation}`) + const directory = await writePlugin(root, `live-fixture-${generation}`, { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + roots[`github:owner/repository#${generation}&path:/.dsh-plugin`] = directory + } + vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async (specifier) => { + const directory = roots[specifier] + if (directory === undefined) throw new Error(`unprepared generation ${specifier}`) + return directory + }) + + // Route the row through the Loader builtin table exactly as a config tree + // would; the module itself is the row's plugin. + const ctx2 = new Context() + await ctx2.plugin(Loader) + await ctx2.plugin(SkillService) + ctx2.loader.builtins['repository-plugins'] = RepositoryPlugin + const entryId = await ctx2.loader.create({ + name: 'cordis:repository-plugins', + config: { repositories: ['github:owner/repository#one'] }, + }) + await ctx2.loader.await() + await expect(ctx2.skills.get('live-skill-one')).resolves.toMatchObject({ provider: 'repository:live-fixture-one' }) + + const entry = ctx2.loader.resolve(entryId) + await entry.update({ config: { repositories: ['github:owner/repository#two'] } }) + await ctx2.loader.await() + await expect(ctx2.skills.get('live-skill-one')).resolves.toBeUndefined() + await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' }) + + // A failed candidate (unprepared source) rejects the update and the + // transactional Loader restores the previous generation. + await expect(entry.update({ config: { repositories: ['github:owner/repository#missing'] } })) + .rejects.toThrow('unprepared generation') + await ctx2.loader.await() + await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' }) + await ctx2.fiber.dispose() + }) + it('rejects duplicate generations and cleans the builtin after cache preparation fails', async () => { const ctx = new Context() await ctx.plugin(Loader) @@ -368,6 +415,28 @@ describe('configured GitHub repository sources', () => { await ctx.fiber.dispose() }) + it('rejects a wrapper left pending by a composition without its required services', async () => { + // A skills-declaring generation mounted where no skills service exists: + // the wrapper fiber stays PENDING, and the transaction must fail loud + // instead of committing an ACTIVE row over a silently inert child. + const root = await temporaryDirectory('pending-services') + await writeSkill(join(root, 'skills'), 'pending-service-skill') + const directory = await writePlugin(root, 'pending-service-fixture', { skills: ['../skills'] }) + await RepositoryPlugin.prepareDshPlugin(directory) + + const ctx = new Context() + await ctx.plugin(Loader) + // Deliberately NO SkillService. + await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, 'github:owner/repository#pending&path:/.dsh-plugin')) + .rejects.toMatchObject({ + message: expect.stringContaining('failed to load prepared repository Plugin') as string, + cause: expect.objectContaining({ + message: expect.stringContaining('waiting for services: skills') as string, + }) as Error, + }) + await ctx.fiber.dispose() + }) + it('labels a missing prepared wrapper with its exact source and path', async () => { const root = await temporaryDirectory('missing-wrapper') const ctx = new Context() diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 26747ef545..15cf4918ad 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -323,8 +323,11 @@ export async function watchPersonalPatches( const entry = bootstrapIncludes.get(ctx) if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`) const filename = join(dir, PERSONAL_CONFIG_FILENAME) - const { patches: _initialPatches, ...includeConfig } = entry.options.config as Include.Config return hmr.registerConfig(filename, async () => { + // Re-read the include's non-patch options per refresh: a writer that + // updates the root Include's other options between refreshes (none exists + // today) must not have them silently reverted by a personal reload. + const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config const personalPatches = loadPersonalPatches(binName, dir) ?? [] const patches = compose(personalPatches) await entry.update({ From c96f48e176e826758299d4b90be8d59fb236b48b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:38:13 +0800 Subject: [PATCH 021/114] fix(review): tolerate mid-teardown personal-watcher registration CI caught a startup race the PTY smoke stages naturally: a TUI /exit typed while watchPersonalPatches is still opening its watcher disposes the tree, and the HMR effect registration rejects with INACTIVE_EFFECT, crashing an app that exited exactly as asked. Return a no-op disposer for that exact code; every other registration failure still propagates (covered both ways). --- packages/ui/app-boot/src/index.ts | 12 ++++++- .../ui/app-boot/tests/personal-config.spec.ts | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 15cf4918ad..eb5003f72b 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -323,7 +323,7 @@ export async function watchPersonalPatches( const entry = bootstrapIncludes.get(ctx) if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`) const filename = join(dir, PERSONAL_CONFIG_FILENAME) - return hmr.registerConfig(filename, async () => { + const register = hmr.registerConfig(filename, async () => { // Re-read the include's non-patch options per refresh: a writer that // updates the root Include's other options between refreshes (none exists // today) must not have them silently reverted by a personal reload. @@ -337,6 +337,16 @@ export async function watchPersonalPatches( }, }) }) + try { + return await register + } catch (error) { + // A surface can dispose the whole tree while the watcher is still opening + // (a TUI `/exit` typed during startup): the HMR effect registration then + // fails with INACTIVE_EFFECT. That is the app exiting exactly as asked, + // not a watch failure — return a no-op disposer instead of crashing. + if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {} + throw error + } } /** diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/personal-config.spec.ts index 62003599d8..53df1d84b7 100644 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ b/packages/ui/app-boot/tests/personal-config.spec.ts @@ -233,4 +233,38 @@ describe('boot with personal patches', () => { await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry') await withoutInclude.fiber.dispose() }) + + it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => { + // A TUI `/exit` typed during startup disposes the whole tree while + // registerConfig's effect registration is still in flight (the HMR effect + // then fails with INACTIVE_EFFECT); the app is exiting exactly as asked, + // so the watcher must not crash the process. The stub makes the race + // deterministic — the live-teardown ordering itself is not stageable. + const dir = tmp() + const ctx = await boot(NAME, writeTree(dir)) + try { + const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' }) + ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) }) + const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: tmp() }) + await expect(dispose()).resolves.toBeUndefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('propagates registration failures other than mid-teardown', async () => { + const dir = tmp() + const personal = tmp() + const ctx = await boot(NAME, writeTree(dir)) + try { + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) + // Same personal path registered twice: HMR refuses; not a teardown race. + await expect(watchPersonalPatches(ctx, { binName: NAME, dir: personal })).rejects.toThrow('already registered') + await dispose() + } finally { + await ctx.fiber.dispose() + } + }) }) From 23610c6abdc5cc7c8fdb453a946a1dc4d2a40c5e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:25:24 +0800 Subject: [PATCH 022/114] fix(goal): emit bare GoalRef in fold lastRef and goal/changed notifications goalChangeRef returned the full GoalSnapshot for every snapshot operation, so foldGoal(...).lastRef and the goal/changed notification ref carried objective, phase, and maxGoalRounds fields instead of the declared GoalRef { id, revision }. Only the clear tombstone was bare. Emit an exact { id, revision } ref for snapshot changes and pin the contract with a regression test covering create/edit/block notifications and the fold. --- packages/goal/goal/src/fold.ts | 4 +++- packages/goal/goal/tests/goal.spec.ts | 21 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index 2ea83029cf..ee765aaeab 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -261,7 +261,9 @@ function validateSnapshotTransition( * @returns stable identity used to reconcile a deferred change with its log event. */ export function goalChangeRef(change: GoalChangeMeta): GoalRef { - return change.operation === 'clear' ? change.cleared : change.goal + return change.operation === 'clear' + ? change.cleared + : { id: change.goal.id, revision: change.goal.revision } } /** diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 7c4b4d9b28..82793618c4 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -11,7 +11,7 @@ import GoalService, { foldGoal, renderGoalChange, } from '@deepseek-ai/dsh-goal' -import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' +import type { GoalChangeMeta, GoalChanged, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' type DeferredInjection = UserMessage @@ -381,6 +381,25 @@ describe('GoalService mutations', () => { expect(next.id).not.toBe(goal.id) }) + it('emits bare compare-and-set refs in folded lastRef and goal/changed notifications', async () => { + const { ctx, agent, session } = await harness() + const seen: GoalChanged['ref'][] = [] + ctx.on('goal/changed', (_subject, change) => { seen.push(change.ref) }) + const created = ctx.goals.create(agent, { objective: 'bare refs', maxGoalRounds: 3 }) + const edited = ctx.goals.edit(agent, created, { objective: 'bare refs edited' }) + const blocked = ctx.goals.block(agent, edited, { code: 'bare-blocker', message: 'Bare refs.' }) + // GoalRef is exactly { id, revision }: every notification ref must be bare. + for (const ref of seen) { + expect(Object.keys(ref).sort()).toEqual(['id', 'revision']) + expect(ref).toEqual({ id: created.id, revision: ref.revision }) + } + expect(seen).toHaveLength(3) + // The durable fold's lastRef is the same bare ref, not a full snapshot. + const folded = foldGoal(session.events) + expect(folded.lastRef).toEqual({ id: blocked.id, revision: blocked.revision }) + expect(Object.keys(folded.lastRef as object).sort()).toEqual(['id', 'revision']) + }) + it('keeps per-goal mutation timestamps monotonic when the wall clock moves backward', async () => { vi.useFakeTimers() vi.setSystemTime(100) From 9a07380c230b15fa9c82ed7f667c0458bf1867e1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:17:34 +0800 Subject: [PATCH 023/114] adopt native GitHub stack workflow --- ...-incremental-pr-base-retargeting.i18n.yaml | 6 +- ...6-07-26-incremental-pr-base-retargeting.md | 4 +- ...7-26-incremental-pr-base-retargeting.zh.md | 4 +- ...thub-stacks-and-optional-rebases.i18n.yaml | 6 + ...tive-github-stacks-and-optional-rebases.md | 43 ++++++ ...e-github-stacks-and-optional-rebases.zh.md | 43 ++++++ .../skills/dsh-merging-stacked-prs/SKILL.md | 132 ++++++++++++++---- .agents/skills/dsh-pre-push-checks/SKILL.md | 29 +++- AGENTS.md | 4 +- ...sponding-to-pr-review-on-a-stack.i18n.yaml | 6 +- .../responding-to-pr-review-on-a-stack.md | 30 ++-- .../responding-to-pr-review-on-a-stack.zh.md | 28 ++-- 12 files changed, 267 insertions(+), 68 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md create mode 100644 .agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.zh.md diff --git a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.i18n.yaml index 2e9a5d6402..8bb5febd69 100644 --- a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-26-incremental-pr-base-retargeting.md: e2097ac4c32a926c8c0271df19dbc9796d0ed19d -2026-07-26-incremental-pr-base-retargeting.zh.md: a6c94b66732b6c037fee1b0726b31ecb6f3b48c5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md +2026-07-26-incremental-pr-base-retargeting.md: b2e644d99877b4214b5a6edb2775b3962e6b7da2 +2026-07-26-incremental-pr-base-retargeting.zh.md: 5014fef644f9907c4d16a9a2a3767d6f55633688 diff --git a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md index e2097ac4c3..b2e644d998 100644 --- a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md +++ b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md @@ -10,9 +10,9 @@ A PR base can advance while its current tip is being merged into the PR branch. ## Decision -Each observed base tip gets its own merge checkpoint. If the base advances during the work, finish and validate the merge already in progress, commit it, and push it when the task authorizes a push. Only then fetch and merge the newer base in a separate merge commit. Never abandon, amend, rebase, or otherwise rewrite the earlier work. +When merge-forward is chosen, each observed base tip gets its own merge checkpoint. If the base advances during the work, finish and validate the merge already in progress, commit it, and push it when the task authorizes a push. Only then fetch and merge the newer base in a separate merge commit. Do not abandon or rewrite a checkpoint within that merge-forward sequence. -The root [AGENTS.md](../../../../AGENTS.md) states the standing order. The [stacked-PR landing skill](../../../skills/dsh-merging-stacked-prs/SKILL.md) applies it while retargeting dependent PRs, and the [stack review guide](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) owns merging fixes down a stack. +The [native-stack and optional-rebase decision](2026-08-02-native-github-stacks-and-optional-rebases.md) also permits a lease-protected rebase for standalone or stacked PRs, including after review. This note owns the merge-forward path only. The [stacked-PR landing skill](../../../skills/dsh-merging-stacked-prs/SKILL.md) selects either history under the root [AGENTS.md](../../../../AGENTS.md), and the [stack review guide](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) owns propagating fixes through dependent layers. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.zh.md b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.zh.md index a6c94b6673..5014fef644 100644 --- a/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -每次观察到的 base 分支顶端提交都保留为独立的合并检查点。如果处理期间 base 分支继续前移,先完成并验证正在进行的合并,再将其提交;任务授权推送时,还要完成推送。完成这些步骤后,才能拉取较新的 base,并通过单独的合并提交将其合入。绝不放弃先前工作,也不通过 amend、rebase 或其他方式重写它。 +选择 merge-forward 时,每次观察到的 base 分支顶端提交都保留为独立的合并检查点。如果处理期间 base 分支继续前移,先完成并验证正在进行的合并,再将其提交;任务授权推送时,还要完成推送。完成这些步骤后,才能拉取较新的 base,并通过单独的合并提交将其合入。在这条 merge-forward 序列中,不得放弃或重写任何检查点。 -根 [AGENTS.md](../../../../AGENTS.md) 规定了这项常设指令。[堆叠 PR 落地 skill(技能)](../../../skills/dsh-merging-stacked-prs/SKILL.md)在调整依赖 PR 的 base 时执行这一规则,[堆叠评审指南](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md)则负责说明如何将修复沿堆叠向下合并。 +[原生堆叠与可选 rebase 决策](2026-08-02-native-github-stacks-and-optional-rebases.md)也允许独立或堆叠 PR 使用受 lease 保护的 rebase,评审后同样如此。本文只负责 merge-forward 路径。[堆叠 PR 落地 skill(技能)](../../../skills/dsh-merging-stacked-prs/SKILL.md)根据根 [AGENTS.md](../../../../AGENTS.md) 选择其中一种历史更新方式,[堆叠评审指南](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md)则负责说明如何在依赖层之间传播修复。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.i18n.yaml b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.i18n.yaml new file mode 100644 index 0000000000..96754a36f3 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.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/process/2026-08-02-native-github-stacks-and-optional-rebases.md +2026-08-02-native-github-stacks-and-optional-rebases.md: a349ed18a27ab006384310e4318f057dbf8873b1 +2026-08-02-native-github-stacks-and-optional-rebases.zh.md: 0205eb475bfe951f8382d61bf19df988027afb13 diff --git a/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md new file mode 100644 index 0000000000..a349ed18a2 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md @@ -0,0 +1,43 @@ +# Agent Note: Native GitHub stacks and optional PR rebases + +Status: implemented + +English | [中文](2026-08-02-native-github-stacks-and-optional-rebases.zh.md) + +## Problem + +A dependent PR chain represented only by base branches has no official stack identity. Landing it requires manually merging one PR at a time, preserving intermediate branches, retargeting every child, and reconstructing whether the chain survived. GitHub's native stacked-PR feature instead carries the order, applies trunk rules and CI to every layer, and owns bottom-up merges and retargeting. + +A blanket prohibition on rewriting reviewed branches also excludes the native `gh stack` synchronization workflow, whose cascading rebase updates each active layer and publishes it with lease protection. Applying that prohibition only outside stacks would give standalone and stacked PRs inconsistent history choices. + +## Decision + +Every same-repository chain of two or more dependent PRs uses GitHub's official stack object before landing. Live `PullRequest.stack` and `stackEntry.position` fields are authoritative. An unstacked chain whose PRs have one author is linked automatically in bottom-to-top order with `gh stack link`; mixed or unavailable authors require user confirmation. Missing native support and cross-fork chains hard-stop. Existing membership in conflicting stacks or an official order that disagrees with the branch topology requires user direction before any stack is dissolved or rebuilt. + +"Land the stack" merges the complete official stack through `gh stack merge --yes --merge`. A partial landing requires an explicit boundary PR and merges the bottom prefix through that PR. The workflow never falls back to per-PR `gh pr merge` and manual retargeting. A direct native merge is all-or-nothing; a merge queue may process the selected PRs in separate groups, so every selected PR must independently reach `MERGED` before the landing is complete. + +Merge-forward and rebase are both allowed refresh histories for standalone and officially stacked PRs, including after review. A remote history rewrite uses an exact lease or the lease-protected `gh stack` push path and aborts if the remote moved; raw `--force` is forbidden. The [incremental base-retargeting decision](2026-07-26-incremental-pr-base-retargeting.md) remains the owner of the merge-forward option. + +Relevant checks normally run before publication. `gh stack sync` is the explicit exception because it fetches, cascade-rebases, and pushes as one operation: every rewritten layer is validated immediately afterward, and no affected PR merges until that evidence passes. After any rewritten push, current heads, unresolved review threads, approvals, mergeability, and checks are re-audited because earlier commit OIDs and inline anchors may be outdated. + +## Verification + +The [stack landing skill](../../../skills/dsh-merging-stacked-prs/SKILL.md) verifies native support, same-repository branches, live authors, official membership and order, merge range, and final merged state. The [stack review guide](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) keeps fixes on their introducing layer and covers both propagation histories. The [pre-push workflow](../../../skills/dsh-pre-push-checks/SKILL.md) owns lease protection and immediate post-sync evidence. + +## Alternatives considered + +**Keep branch chains as the only stack representation.** This preserves the manual procedure but gives GitHub no stack object through which to show order, enforce trunk rules across every layer, or merge a range atomically. + +**Adopt native stacks while forbidding their rebase commands after review.** This keeps commit OIDs stable but disables the official synchronization path when a stack is under active review and leaves standalone PRs under a different policy. + +**Require rebase for every PR refresh.** A linear history is useful, but merge checkpoints remain a valid choice when preserving completed conflict resolution and its recovery point matters more than compact history. + +**Automatically dissolve conflicting stacks.** This would make local branch inference override shared GitHub metadata and could disturb PRs or authors outside the requested chain; merged and queued entries cannot always be removed. + +## Consequences + +- Reviewers and automation receive GitHub's stack map, stack-wide rules, CI, and native merge state. +- A same-author legacy chain becomes official without an extra prompt, while mixed ownership and conflicting metadata retain a human decision boundary. +- Rebases can invalidate commit hashes, approvals, or comment anchors after review, so every rewritten push carries a live review and check audit. +- `gh stack sync` can briefly publish code whose local evidence is pending; the affected PRs remain blocked from merging until immediate post-sync validation passes. +- Merge-forward remains available and preserves completed checkpoints, at the cost of additional merge commits. diff --git a/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.zh.md b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.zh.md new file mode 100644 index 0000000000..0205eb475b --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.zh.md @@ -0,0 +1,43 @@ +# Agent Note: GitHub 原生堆叠与可选 PR rebase + +Status: implemented + +[English](2026-08-02-native-github-stacks-and-optional-rebases.md) | 中文 + +## 问题 + +仅以 base 分支表示的依赖 PR(Pull Request)链没有官方的堆叠身份。要让它落地,就必须逐个手动合并 PR、保留中间分支、调整每个子 PR 的 base,并重新查证这条链是否仍然完整。GitHub 原生的堆叠 PR 功能则会承载顺序,对每一层应用 trunk 规则和 CI,并负责自底向上的合并与 base 调整。 + +一概禁止改写已评审分支,也会排除原生的 `gh stack` 同步工作流:该工作流通过级联 rebase 更新每个活跃层,并在 lease 保护下发布。如果只在堆叠之外实施这项禁令,就会让独立 PR 和堆叠 PR 面临不一致的历史选择。 + +## 决策 + +同一仓库内由两个或更多个相互依赖的 PR 组成的每条链,在落地前都必须使用 GitHub 的官方 stack 对象。以实时 `PullRequest.stack` 和 `stackEntry.position` 字段为权威依据。对于尚未形成官方堆叠且所有 PR 作者相同的链,系统使用 `gh stack link` 按自底向上的顺序自动关联;作者不一或作者信息不可用时,必须取得用户确认。缺少原生支持或跨 fork 的链会使流程硬性停止。如果现有成员属于相互冲突的堆叠,或者官方顺序与分支拓扑不一致,则在解散或重建任何堆叠之前都必须取得用户指示。 + +「落地堆叠」通过 `gh stack merge --yes --merge` 合并整个官方堆叠。部分落地需要明确指定边界 PR,并合并从底部到该 PR 的前缀。工作流绝不回退到逐个执行 `gh pr merge` 和手动调整 base。原生直接合并要么全部成功,要么全部不合并;合并队列可能分组处理所选 PR,因此只有每个所选 PR 都分别达到 `MERGED`,落地才算完成。 + +merge-forward 和 rebase 都可以作为独立 PR 与官方堆叠 PR 的历史刷新方式,包括评审后。改写远端历史时,必须使用精确 lease 或受 lease 保护的 `gh stack` 推送路径;如果远端已经前移,操作必须中止。禁止直接使用 `--force`。[增量更新 base 的决策](2026-07-26-incremental-pr-base-retargeting.md)仍负责 merge-forward 选项。 + +相关检查通常在发布前运行。`gh stack sync` 是明确的例外,因为它在一次操作中完成获取、级联 rebase 和推送:随后立即验证每个已改写的层;这些验证通过前,不得合并任何受影响的 PR。每次改写推送后,都要重新审计当前 head、未解决的评审线程、批准状态、可合并性和检查结果,因为先前的 commit OID 和内联锚点可能已经过时。 + +## 验证 + +[堆叠落地 skill(技能)](../../../skills/dsh-merging-stacked-prs/SKILL.md)验证原生支持、同仓库分支、实时作者信息、官方成员关系与顺序、合并范围以及最终合并状态。[堆叠评审指南](../../../../docs/cookbook/responding-to-pr-review-on-a-stack.md)让修复留在引入问题的层,并涵盖两种用于传播修复的历史策略。[推送前工作流](../../../skills/dsh-pre-push-checks/SKILL.md)负责 lease 保护和同步后立即验证所得的证据。 + +## 曾考虑的替代方案 + +**仅以分支链表示堆叠。** 这种做法保留手动流程,但 GitHub 没有 stack 对象可用于展示顺序、对每一层执行 trunk 规则或以原子操作合并整个范围。 + +**采用原生堆叠,但禁止在评审后使用其 rebase 命令。** 这会保持 commit OID 稳定,但也会在堆叠正在接受评审时禁用官方同步路径,并让独立 PR 遵循不同的政策。 + +**要求每次刷新 PR 都使用 rebase。** 线性历史很有价值,但当保存已经完成的冲突解决及其恢复点比紧凑历史更重要时,合并检查点仍然是有效选择。 + +**自动解散相互冲突的堆叠。** 这会让本地分支推断凌驾于共享的 GitHub 元数据之上,并可能干扰所请求链之外的 PR 或作者;已经合并或进入队列的条目不一定都能移除。 + +## 后果 + +- 评审者和自动化会获得 GitHub 的堆叠图、覆盖整个堆叠的规则、CI 和原生合并状态。 +- 同一作者的遗留链无需额外询问即可成为官方堆叠;链由多名作者共同拥有或元数据发生冲突时,仍保留人工决策边界。 +- 评审后,rebase 可能使 commit hash、批准状态或评论锚点失效,因此每次改写推送后都要对实时评审状态和检查结果进行审计。 +- `gh stack sync` 可能短暂发布本地验证仍待完成的代码;受影响的 PR 在同步后立即验证通过前仍禁止合并。 +- merge-forward 仍然可用,并以增加合并提交为代价保留已完成的检查点。 diff --git a/.agents/skills/dsh-merging-stacked-prs/SKILL.md b/.agents/skills/dsh-merging-stacked-prs/SKILL.md index 50ceda2233..4bcb568955 100644 --- a/.agents/skills/dsh-merging-stacked-prs/SKILL.md +++ b/.agents/skills/dsh-merging-stacked-prs/SKILL.md @@ -1,53 +1,127 @@ --- name: dsh-merging-stacked-prs -description: Use when landing a stack of dependent GitHub PRs (A ← B ← C, where each bases on the one below) onto master — merging more than one PR in a chain, merging a PR whose base is another open PR's branch, or whenever a request mentions "stacked PRs", "PR stack", "dependent PRs", "base branch", or merging several related PRs in sequence. Critical because deleting a base branch mid-chain auto-closes the open PR that bases on it — get the order wrong and you silently close unmerged work. +description: Use when landing a stack of dependent GitHub PRs (A ← B ← C, where each bases on the one below) onto master, merging a PR whose base is another open PR's branch, or whenever a request mentions "stacked PRs", "PR stack", "dependent PRs", or merging several related PRs in sequence. Requires every same-repository dependency chain to use GitHub's official stacked-PR feature before landing so GitHub owns stack-wide rules, CI, ordering, retargeting, and merge state. --- -# Merging a stacked PR chain +# Landing an official GitHub PR stack -This skill is the landing procedure for a dependent PR stack. The standing orders it rests on — merge commits only (`gh pr merge --merge`), never rewrite a pushed branch — live in the root [AGENTS.md](../../../AGENTS.md) § Conventions; the discipline for handling review comments across a stack before it lands is the [responding-to-pr-review-on-a-stack](../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) cookbook guide. +Land dependent PRs through GitHub's native stack object and `gh stack merge`. Do not reproduce stack semantics by merging and retargeting individual PRs with `gh pr merge` and `gh pr edit`. The root [AGENTS.md](../../../AGENTS.md) owns the allowed merge-forward and rebase histories; the [stack review guide](../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) owns review-fix propagation. -## The hazard this prevents +## Require native stack support -On GitHub, **deleting a PR's base branch auto-closes that PR.** In a stack `A ← B ← C` (B bases on A, C bases on B), branch A is the base of PR B, and branch B is the base of PR C. So if you merge A with `--delete-branch`, GitHub closes PR B before it's merged — silently destroying the chain. The whole procedure below exists to avoid that: **merge one at a time, retarget each dependent as you go, and delete nothing until every PR has landed.** +Run `gh stack --version` before changing GitHub state. Hard-stop if the official extension or server-side stack feature is unavailable; do not fall back to the legacy manual landing procedure. GitHub stacks require every head branch to live in the same repository, so hard-stop on a cross-fork chain. -## The procedure +Use a clean dedicated worktree. Fetch current PR metadata and exact head OIDs rather than trusting branch names or an earlier report: -Given `A ← B ← C` landing on `master`: +```sh +gh pr view --json number,author,baseRefName,baseRefOid,headRefName,headRefOid,isCrossRepository,state,isDraft,reviewDecision,mergeStateStatus,statusCheckRollup +``` -1. **Merge PR A into master, keeping its branch.** `gh pr merge A --merge` — no `--delete-branch`. Branch A must survive because PR B still bases on it. Before touching the next link, confirm the merge actually landed: with required checks pending or a merge queue, `gh pr merge` may only enable auto-merge and return early, so wait until `gh pr view A --json state` reports `MERGED`. This applies after every merge in the stack. +Query `PullRequest.stack` and `stackEntry.position` for at least one PR in each apparent chain; this official GitHub object, not base-branch inference alone, is the stack-membership authority. Paginate `entries` when `size` exceeds the returned page: -2. **Retarget PR B, refresh it, then merge it — keeping its branch.** - - `gh pr edit B --base master` (now that A is in master, B's base becomes master). - - Merge the new master *into* branch B: check out B, `git fetch origin`, `git merge origin/master` — merge `origin/master`, not local `master`, because `gh pr merge` updated only GitHub and the local branch is stale — resolve any conflicts here, and push. This makes B current and surfaces conflicts in the working branch where they can be tested — not as a surprise at the GitHub merge. - - If `origin/master` moves during that work, finish and push the in-progress merge, then fetch and merge the newer tip in a separate commit. Never abandon or rewrite the earlier work ([rationale](../../notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). - - `gh pr merge B --merge` — still no `--delete-branch` (PR C bases on branch B). +```sh +gh api graphql -F owner= -F name= -F number= -f query=' +query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + number + author { login } + baseRefName + headRefName + stackEntry { position } + stack { + number + baseRefName + size + entries(first: 100) { + nodes { + position + pullRequest { number author { login } baseRefName headRefName state isDraft } + } + } + } + } + } +}' +``` -3. **Retarget PR C, refresh it, then merge it — keeping its branch.** Same steps: `gh pr edit C --base master`, fetch and merge `origin/master` into branch C, resolve conflicts there and push, then `gh pr merge C --merge` without `--delete-branch`. +Establish the expected bottom-to-top order from the live PR bases: the bottom targets the trunk, and each higher PR targets the head branch immediately below it. -4. **Only after every PR (A, B, C) is merged, delete the branches** — local and remote, for all of A, B, C. +## Link missing stack members -## Why "merge new master into the dependent before merging it" +First compare any existing stack entries with the expected chain. One existing stack may contain an order-preserving subset of the requested chain; multiple stack numbers, an unexpected entry, or a conflicting order requires user direction before any mutation. -Each retarget step merges the freshly-updated master back into the dependent branch *before* merging the PR. This keeps each PR's diff clean (it only shows that PR's own changes, not the parent's) and forces conflicts to surface in the working branch, where you can build and test the resolution — instead of letting GitHub attempt a blind merge that may conflict or quietly mis-resolve. +When any dependent PR is not yet in that official stack: -## Verify before deleting anything +1. Compare every `author.login` exactly. +2. If all authors match, link the chain automatically in bottom-to-top order: -Before deleting a branch, ask GitHub directly whether any open PR still bases on it: +```sh +gh stack link --base ... +``` + +3. If authors differ or any author is unavailable, ask the user whether to link before changing GitHub state. +4. Re-query GraphQL and require one stack number, the expected trunk, the complete PR set, and the expected positions and base chain. + +Never dissolve, reorder, or rebuild an existing stack automatically; `gh stack link` is additive and merged or queued entries cannot be unstacked. + +## Refresh only when needed + +Do not rewrite branches merely because a refresh mechanism exists. When the live merge state or repository rules require an updated trunk, choose either allowed history: + +- **Native cascading rebase:** check out the remote stack with `gh stack checkout ` when it is not tracked locally, then run `gh stack sync`. The command may rebase and lease-protected force-push every active layer before local validation. Immediately inspect the rewritten scope, run the relevant checks for every affected layer, and do not merge or claim readiness until they pass. If sync detects a rebase conflict, use `gh stack rebase`, resolve and validate it, then publish with `gh stack push`. If checkout or sync reports divergent local and remote stack compositions, cancel and ask rather than deleting or recreating the remote stack automatically. +- **Incremental merge-forward:** merge the trunk into the bottom affected branch, then propagate each updated parent into its child in bottom-to-top order and push normally. If the base advances during an in-progress merge, preserve that checkpoint before merging the newer tip as specified by the [incremental-retargeting note](../../notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md). + +Any history rewrite is allowed after review, but it invalidates commit-OID assumptions. Re-fetch exact heads and re-audit unresolved review threads, approvals, mergeability, and checks after the push. Never use raw `--force` or overwrite a concurrently advanced remote head. + +## Preflight the merge range + +Re-query the official stack immediately before merging. Require every selected PR to be open, non-draft, in the expected order, and compliant with the repository's review and check requirements. Treat each PR's state independently; a ready top layer does not prove its dependencies are ready. + +"Land the stack" selects the whole stack. A partial landing requires an explicit boundary PR and includes every layer from the bottom through that boundary. + +## Merge through the stack API + +Merge the whole stack by its official stack number: + +```sh +gh stack merge --yes --merge +``` + +For an explicitly requested partial landing, merge through the boundary PR: + +```sh +gh stack merge --yes --merge +``` + +Do not pass `--delete-branch`, manually retarget dependents, or issue per-PR merge commands. GitHub merges the selected range bottom-up and retargets/rebases any remaining upper layers. A direct stack merge is all-or-nothing; when the trunk uses a merge queue, GitHub queues the selected range together but may land it in separate groups. + +Do not bypass merge requirements. If the native merge reports a blocker, inspect and resolve that blocker through the owning PR or stop and report it; never fall back to `gh pr merge`. + +## Verify the landed state + +Wait for every selected PR to report `MERGED`; a queued request is not a completed landing: + +```sh +gh pr view --json number,state,mergedAt,mergeCommit,baseRefName,headRefName +``` + +For a partial landing, re-query the official stack and verify that every remaining PR is still linked in the expected order and targets the stack trunk or the layer below it. Re-check current heads, review state, and CI because GitHub may have rebased the remaining layers. + +Delete branches only in a separate final pass after the corresponding PRs report `MERGED`. Before deleting each branch, require GitHub to report no open PR still using it as a base: ```sh gh pr list --state open --base --json number --jq length ``` -Anything other than `0` means open PRs still base on `` and deleting it would auto-close them — do not delete it. The `--base` filter is applied server-side, so zero-versus-non-zero is exact no matter how many PRs are open; the printed number itself saturates at `gh`'s `--limit` (default 30), which never matters here because only `0` clears a delete. Default to merging without `--delete-branch` throughout, and do the deletions as a separate final pass once every branch you're about to delete reports `0`. +Anything other than `0` blocks deletion. -## Longer chains +## Checklist -The pattern extends to any depth. For `A ← B ← C ← D ← …`, walk the stack from the bottom up: merge the lowest, then for each next link retarget to master, fetch and merge `origin/master` into it, merge the PR — always without deleting — and only sweep up all the branches at the very end. The invariant never changes: **a branch may be deleted only when no open PR bases on it.** - -## Quick checklist - -- [ ] Merge bottom PR first, `--merge`, no `--delete-branch`; wait until `gh pr view --json state` shows `MERGED`. -- [ ] For each dependent: `gh pr edit --base master` → fetch and merge `origin/master` into the branch (resolve conflicts there, push) → `gh pr merge --merge`, no `--delete-branch`; again wait for `MERGED`. -- [ ] Before each branch delete: `gh pr list --state open --base --json number --jq length` prints `0`. -- [ ] Delete all branches (local + remote) only as a final pass. +- [ ] Native `gh stack` support is available; every PR branch is in the same repository. +- [ ] Live PR bases and exact heads establish one bottom-to-top dependency chain. +- [ ] GraphQL reports one official stack with the expected trunk, entries, and order; an eligible same-author unstacked chain was linked automatically. +- [ ] Any rewritten layers passed relevant validation, and review threads, approvals, mergeability, and checks were re-audited afterward. +- [ ] The whole stack, or an explicitly bounded prefix, was submitted through `gh stack merge --yes --merge`. +- [ ] Every selected PR reports `MERGED`; any remaining upper layers still form the expected official stack. +- [ ] Branch deletion happened only after merged-state and zero-dependent verification. diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index fe5de961a9..dd04cf9b33 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -1,11 +1,11 @@ --- name: dsh-pre-push-checks -description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select the smallest tests and checks that cover the outgoing diff without reflexively running the full repository suite. +description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch, and immediately after gh stack sync publishes rewritten branches, to select the smallest tests and checks that cover the outgoing or just-published diff without reflexively running the full repository suite. --- # DSH Pre-Push Checks -Use this skill to run relevant local evidence once before a `deepseek-harness` push. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix. +Use this skill to run relevant local evidence once before a `deepseek-harness` push. The sole ordering exception is `gh stack sync`, which may publish a cascading rebase before the rewritten layers can be validated; validate them immediately afterward and do not merge until the evidence passes. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix. ## Inspect the outgoing change @@ -63,9 +63,26 @@ pnpm exec vitest related packages///src/.ts \ Run the complete local approximation only when the user explicitly requests it, while diagnosing a CI failure, or when the change spans the repository so broadly that no narrower set is credible. Use the current workflow and package scripts as the inventory; do not recreate the removed `check:pre-push` aggregate. +## Protect history-rewriting pushes + +Rebase is allowed for standalone and stacked PR branches, including after review. Before a standalone history rewrite, fetch the current remote branch and record its exact OID; publish with `--force-with-lease=:` so a concurrent update aborts the push. `gh stack push` and `gh stack sync` supply lease protection for their managed branches. Raw `--force` is never allowed. + +After any rewritten push, fetch the live heads again and re-audit unresolved review threads, approvals, mergeability, and checks. Commit hashes and inline-comment anchors from before the rewrite are not current evidence. + +### Post-sync validation + +`gh stack sync` fetches, cascade-rebases, and pushes as one operation, so it cannot place local validation between rewrite and publication. Before running it, require a clean worktree and record the official stack order and exact remote heads. After it returns: + +1. Re-query every branch head and the official GitHub stack order. +2. Inspect the changed scope of every rewritten layer against its live PR base. +3. Run the relevant evidence selected by this skill for each affected layer. +4. Keep every PR unmerged and report validation as pending until all selected checks pass. + +If post-sync evidence fails, leave the lease-protected published heads in place, repair the failure, validate the repair, and publish the correction. Do not claim the sync made the stack ready merely because the command succeeded. + ## Handle failures -If a relevant check fails, stop and fix or explain the blocker. Do not push and hope CI differs. +If a relevant check fails before an ordinary push, stop and fix or explain the blocker. Do not push and hope CI differs. For the post-sync exception, block the merge and follow the repair procedure above. If a failure looks environment-specific, prove it: @@ -76,9 +93,11 @@ If a failure looks environment-specific, prove it: ## Push procedure +For ordinary and standalone rebase pushes: + 1. Run the selected relevant checks once. 2. Commit normally and inspect any files changed by the pre-commit fixer before continuing. -3. Push normally so the incremental typecheck hook runs. +3. Push normally, or use the exact lease for an authorized rewritten branch, so the incremental typecheck hook runs. 4. Verify the remote ref matches local `HEAD`. ```sh @@ -92,3 +111,5 @@ gh pr checks ``` Report pending checks as pending. Inspect failures before attributing them to the branch or the environment. + +For `gh stack sync`, use the post-sync validation sequence instead of pretending the ordinary order was possible. diff --git a/AGENTS.md b/AGENTS.md index 9667432285..b7128ff89d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ When required `gh`, `pnpm`, build, test, or generator commands fail because the ### Run relevant checks locally -Agents MUST run relevant tests and checks before pushing; select them with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run. +Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md); report only commands run. After `gh stack sync`, validate immediately; do not merge before checks pass. - Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior. - Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change. @@ -116,7 +116,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. -- **Use incremental merge commits.** Split independent changes. Pushed history may be rewritten before review; afterward prefer new commits. Fix the introducing PR before merging down-stack. If the base advances mid-merge, finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). +- **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). - **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml b/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml index d75b8cad3e..86a72161b2 100644 --- a/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -responding-to-pr-review-on-a-stack.md: 3fb7eb943eeb8d703303be3f6a844870cc26fd47 -responding-to-pr-review-on-a-stack.zh.md: d96323b853c093265931904c20996df335f82926 +# pnpm run verify-translation-pairing --write docs/cookbook/responding-to-pr-review-on-a-stack.md +responding-to-pr-review-on-a-stack.md: 6bb7be3daf8613666f02f21e96bf7b1ac8cb3606 +responding-to-pr-review-on-a-stack.zh.md: 94c2bd2cdd03b9eeca1e180b09bb71471db91c0a diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.md b/docs/cookbook/responding-to-pr-review-on-a-stack.md index 3fb7eb943e..6bb7be3daf 100644 --- a/docs/cookbook/responding-to-pr-review-on-a-stack.md +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.md @@ -2,25 +2,31 @@ English | [中文](responding-to-pr-review-on-a-stack.zh.md) -Review comments may target several PRs in a dependent stack (`A ← B ← C …`). This guide explains how to resolve them without corrupting the stack. The two invariants it rests on are standing orders in the root [AGENTS.md](../../AGENTS.md) § Conventions: merge commits only, and never rewrite a pushed branch. +Review comments may target several PRs in a dependent stack (`A ← B ← C …`). Keep that chain linked through GitHub's official stacked-PR feature. This guide owns review-fix placement and propagation; the [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill owns linkage checks and landing. ## Ground rules 1. **One worktree per PR branch.** Each PR's fixes happen in that PR's own worktree; parallel fixes never share a checkout. -2. **Bring a child up to date by merging the parent down** (`git merge ` into the child, a new merge commit). Never rebase/amend/force-push a pushed branch: rewriting diverges it from what the parent PR and GitHub recorded, breaks the stacked-merge graph, and erases the review-fix history. -3. **A fix lands on the PR that INTRODUCED the issue, then flows down.** When a comment on PR `B` points at code `B` introduced, fix it on `B` and merge `B` into `C` — even if `C` also carries the file. Originating the fix downstream leaves `B` shipping the unfixed code and hides the fix from `B`'s reviewer. -4. **Each review fix is a separate commit, never an amend.** The "fix review findings" commit documents what the review caught. Amending is fine only for your own not-yet-pushed, not-yet-reviewed work. +2. **GitHub's stack object is authoritative.** Base branches establish the expected dependency order, while `PullRequest.stack` and `stackEntry.position` prove that GitHub recognizes it. Do not treat a matching branch chain as an official stack without checking those fields. +3. **A fix lands on the PR that INTRODUCED the issue, then flows up-stack.** When a comment on PR `B` points at code `B` introduced, fix it on `B` and propagate `B` into `C` — even if `C` also carries the file. Originating the fix downstream leaves `B` shipping the unfixed code and hides the fix from `B`'s reviewer. +4. **Each review fix remains a distinct commit.** A later rebase may change its OID, but do not amend a reviewed fix out of the branch history. Amend only your own not-yet-pushed, not-yet-reviewed work. +5. **Choose merge-forward or rebase deliberately.** Both histories are allowed after review. A rewritten push must be lease-protected and must abort rather than overwrite a concurrently advanced remote head; raw `--force` is forbidden. ## Resolve comments through the stack -1. Triage every comment on the merits before acting: verify the claim against the code — a reviewer flagging the right symptom can still mis-diagnose the cause. -2. Map each accepted finding to its originating PR, fix it there, then merge down the chain in order. -3. Delegated fixes are trust-but-verify: a sub-agent's report describes intent, not necessarily what landed. Re-run the gates yourself on the actual tree, and for a regression guard, prove it FAILS on the unfixed code (introduce the regression, watch red, revert) — a guard that passes both ways guards nothing. A sub-agent that reframes a problem as already-handled is a signal to dig in personally. -4. Reply in the review thread (`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`), not as a top-level comment, stating the fix and the commit that carries it. -5. Before merging the stack, check dependents: deleting a PR's base branch auto-closes the dependent PR — check each branch with `gh pr list --state open --base --json number --jq length` (non-zero = open dependents), and merge without `--delete-branch` where a child still bases on the branch. The full landing procedure is the [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill. +1. Triage every comment on the merits before acting: verify the claim against the code — a reviewer flagging the right symptom can still misdiagnose the cause. +2. Map each accepted finding to its originating PR and fix it there. +3. Propagate the fixed layer through every affected child in order: + - **Merge-forward:** merge the fixed parent branch into its child, validate the child, and continue upward. Preserve each in-progress checkpoint under the [incremental-retargeting decision](../../.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md). + - **Native cascading rebase:** use `gh stack rebase`, validate the rewritten layers, then publish with `gh stack push`; or use `gh stack sync`, which may publish first and therefore requires immediate post-sync validation under [dsh-pre-push-checks](../../.agents/skills/dsh-pre-push-checks/SKILL.md). +4. Treat delegated fixes as trust-but-verify: a sub-agent's report describes intent, not necessarily what landed. Re-run the gates yourself on the actual tree, and for a regression guard, prove it FAILS on the unfixed code (introduce the regression, watch red, revert) — a guard that passes both ways guards nothing. A sub-agent that reframes a problem as already handled is a signal to dig in personally. +5. Reply in the review thread (`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`), not as a top-level comment, stating the fix and the current commit or head that carries it. +6. After any rewritten push, re-read unresolved threads, approvals, mergeability, and checks. A force-pushed commit OID or outdated inline anchor is not current evidence that the finding remains resolved. +7. Land only through the official stack procedure. If the PRs are not yet linked, the landing skill automatically links a same-author chain, asks before linking mixed authors, and hard-stops when native stack support is unavailable. ## Verify -- Every fixed PR shows a new commit (no force-push icon in the PR timeline). -- Each child PR's diff against its parent still shows only its own changes. -- The gates pass on every PR in the stack, not just the top. +- Every fixed PR's current diff contains the intended correction at the layer that introduced the issue. +- GraphQL reports one official stack in the expected order, and each child diff against its parent shows only that child's changes. +- Unresolved threads, approvals, mergeability, and checks were re-audited after every rewritten push. +- The relevant gates pass on every affected PR in the stack, not just the top. diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md b/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md index d96323b853..94c2bd2cdd 100644 --- a/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md @@ -2,25 +2,31 @@ [English](responding-to-pr-review-on-a-stack.md) | 中文 -评审意见可能同时针对一条依赖堆叠(`A ← B ← C …`)中的多个 PR(Pull Request)。本指南说明如何在不破坏堆叠的前提下解决这些意见。它依赖的两个不变式是根 [AGENTS.md](../../AGENTS.md) § Conventions 中的常设指令:只用 merge commit,以及永远不改写已推送的分支。 +评审意见可能同时针对一条依赖堆叠(`A ← B ← C …`)中的多个 PR(Pull Request)。请通过 GitHub 官方的堆叠 PR 功能保持这条链的关联。本指南负责评审修复的归属与传播;[dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill(技能)负责检查关联关系和落地。 ## 基本规则 1. **每个 PR 分支一个 worktree。** 每个 PR 的修复在该 PR 自己的 worktree 中进行;并行修复绝不共享同一个 checkout。 -2. **通过将父分支向下合并来更新子分支**(在子分支中执行 `git merge `,产生一个新的 merge commit)。绝不对已推送的分支做 rebase/amend/force-push:改写会使分支与父 PR 及 GitHub 记录的内容产生分歧,破坏堆叠合并图,并抹去评审修复历史。 -3. **修复落在引入问题的那个 PR 上,然后向下流动。** 当 PR `B` 上的评论指向 `B` 引入的代码时,在 `B` 上修复,再将 `B` 合并到 `C`——即使 `C` 也包含该文件。把修复发起在下游会导致 `B` 带着未修复的代码交付,并对 `B` 的评审者隐藏修复。 -4. **每个评审修复是一个独立 commit,绝不 amend。** "修复评审发现"的 commit 记录了评审捕获的内容。只有你自己尚未推送、尚未评审的工作才可以 amend。 +2. **GitHub 的 stack 对象是权威依据。** base 分支确定预期的依赖顺序,`PullRequest.stack` 和 `stackEntry.position` 则证明 GitHub 已识别该堆叠。未经检查这些字段,不得仅凭分支链吻合就将其视为官方堆叠。 +3. **修复落在引入问题的那个 PR 上,然后沿堆叠向上流动。** 当 PR `B` 上的评论指向 `B` 引入的代码时,在 `B` 上修复,再将 `B` 的变更传播到 `C`,即使 `C` 也包含该文件。把修复发起在下游会导致 `B` 带着未修复的代码交付,并对 `B` 的评审者隐藏修复。 +4. **每项评审修复都保留为独立 commit。** 后续 rebase 可能改变其 OID,但不得通过 amend 把已经评审的修复从分支历史中抹去。只有你自己尚未推送且尚未评审的工作才可以 amend。 +5. **明确选择 merge-forward 或 rebase。** 评审后允许采用这两种历史更新方式。改写历史的推送必须受 lease 保护;如果远端 head 在此期间前移,操作必须中止,不得将其覆盖。禁止直接使用 `--force`。 ## 沿堆叠解决评审意见 1. 在行动之前先就事论事地审视每条评论:对照代码验证其论断——评审者指出了正确的症状,但仍可能误诊原因。 -2. 将每个被接受的发现映射到其发起 PR,在那里修复,然后按顺序沿链向下合并。 -3. 委派的修复需要信任但验证:子 agent(智能体)的报告描述的是意图,不一定是实际落地的内容。请亲自在实际代码树上重新运行门禁;对于回归守卫,要证明它在未修复的代码上**失败**(引入回归、观察变红、再还原)——两种情况都通过的守卫什么也守不住。子 agent 将问题重新定性为「已处理」时,这是一个需要亲自深入的信号。 -4. 在评审线程中回复(`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`),而非发顶层评论;说明修复内容及承载修复的 commit。 -5. 合并堆叠之前,检查依赖方:删除一个 PR 的 base 分支会自动关闭依赖它的 PR。用 `gh pr list --state open --base --json number --jq length` 检查每个分支(非零 = 有打开的依赖方),当子 PR 仍以该分支为 base 时,合并时不带 `--delete-branch`。完整的落地流程见 [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill(技能)。 +2. 将每个被接受的发现映射到其发起 PR,并在那里修复。 +3. 将修复后的层按顺序传播到每个受影响的子 PR: + - **Merge-forward:** 将修复后的父分支合并到其子分支,验证子分支,然后继续沿堆叠向上传播。依照[增量更新 base 的决策](../../.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md),保留每个正在处理的检查点。 + - **原生级联 rebase:** 使用 `gh stack rebase`,验证所有已改写的层,然后通过 `gh stack push` 发布;也可以使用 `gh stack sync`,该命令可能先发布,因此必须按照 [dsh-pre-push-checks](../../.agents/skills/dsh-pre-push-checks/SKILL.md) 在同步后立即验证。 +4. 委派的修复需要信任但验证:子 agent(智能体)的报告描述的是意图,不一定是实际落地的内容。请亲自在实际代码树上重新运行门禁;对于回归守卫,要证明它在未修复的代码上**失败**(引入回归、观察变红、再还原)——两种情况都通过的守卫什么也守不住。子 agent 将问题重新定性为「已处理」时,这是一个需要亲自深入的信号。 +5. 在评审线程中回复(`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`),而非发顶层评论;说明修复内容及当前承载修复的 commit 或 head。 +6. 每次改写推送后,都要重新读取未解决线程、批准状态、可合并性和检查结果。经 force-push 改写的 commit OID 或已过时的内联锚点,都不足以证明该发现当前仍处于已解决状态。 +7. 仅可通过官方堆叠流程落地。如果这些 PR 尚未关联,落地 skill 会自动关联作者相同的链;如果作者不同,则先询问用户;如果原生堆叠支持不可用,则硬性停止流程。 ## 验证 -- 每个已修复的 PR 显示一个新 commit(PR 时间线中没有 force-push 图标)。 -- 每个子 PR 相对其父 PR 的 diff 仍然只包含自身的变更。 -- 门禁在堆叠中的每个 PR 上都通过,而不仅仅是顶部。 +- 每个已修复 PR 的当前 diff 都在引入问题的那一层包含预期修正。 +- GraphQL 报告的官方堆叠只有一个且顺序符合预期;每个子 PR 相对于父 PR 的 diff 只显示该子 PR 自身的变更。 +- 每次改写推送后,均重新审计了未解决线程、批准状态、可合并性和检查结果。 +- 相关门禁在堆叠中的每个受影响 PR 上都通过,而不仅仅是顶部。 From 54ef823be7d3ac5e89e4be6335bc6f3bd21101f6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:56:25 +0800 Subject: [PATCH 024/114] fix(cli): keep session search tools opt-in on shipped surfaces The shipped-roster change made @deepseek-ai/dsh-tool-session-query a default row of the shared base.cordis.yml, so the TUI and Web surfaces put the five session-search tools in front of the model. That contradicts the recorded opt-in stance for the model-facing session query consumer; the ACP example remains the mounted reference. Remove the row from the shared base, the now-dangling disabled patch in the opt-in core-web profile, and the workspace dependency. The ctx.sessionQuery index stays: the TUI's /resume and the Web content search consume it directly. Both shipped-composition tests now pin the 20-tool catalog. --- ...model-facing-session-query-tools.i18n.yaml | 4 +-- ...-07-24-model-facing-session-query-tools.md | 4 +-- ...-24-model-facing-session-query-tools.zh.md | 4 +-- ...31-even-out-shipped-tool-rosters.i18n.yaml | 4 +-- ...026-07-31-even-out-shipped-tool-rosters.md | 8 +++--- ...-07-31-even-out-shipped-tool-rosters.zh.md | 8 +++--- ...ssion-search-not-shipped-default.i18n.yaml | 6 +++++ ...8-02-session-search-not-shipped-default.md | 25 +++++++++++++++++++ ...2-session-search-not-shipped-default.zh.md | 25 +++++++++++++++++++ apps/cli/composition.md | 3 --- apps/cli/config/base.cordis.yml | 6 ----- apps/cli/config/core-web.cordis.yml | 3 --- apps/cli/package.json | 1 - apps/cli/tests/shipped-composition.e2e.ts | 5 ---- apps/web/tests/shipped-composition.e2e.ts | 5 ---- .../tool-session-query/README.i18n.yaml | 4 +-- .../tool-session-query/README.md | 2 +- .../tool-session-query/README.zh.md | 2 +- pnpm-lock.yaml | 3 --- 19 files changed, 76 insertions(+), 46 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md create mode 100644 .agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index ce1ef3af95..b11dc50cd6 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.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-07-24-model-facing-session-query-tools.md -2026-07-24-model-facing-session-query-tools.md: 82fb70349a94916af2e99b83fcbdac765aae3dd0 -2026-07-24-model-facing-session-query-tools.zh.md: 3ffc142b2a27c612bb8a3238823f536871e5ea17 +2026-07-24-model-facing-session-query-tools.md: 863f557f11f89ff8dfc121b7da0b653852528394 +2026-07-24-model-facing-session-query-tools.zh.md: d8deaba15f111537a16deafe73ed6dd708ea044a diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index 82fb70349a..863f557f11 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -36,7 +36,7 @@ Session-level results include the latest folded title when available. Each tool ## Host composition -The consumer is an opt-in plugin. The shipped TUI, Web, and headless compositions mount both `ctx.sessionQuery` and `@deepseek-ai/dsh-tool-session-query` through their shared base, so their default model requests include the query prompt and five schemas; the automation-only ACP composition mounts neither. These compositions also supply the generic timeout and spill policies. The dedicated ACP snapshot fixture mounts the consumer and both policies explicitly, with private local spill storage. Generic tool presentation requires no session-query-specific client plugin. +The consumer is an opt-in plugin. Shipped host compositions do not mount it: the shipped TUI, Web, and headless surfaces keep the `ctx.sessionQuery` index (the SQLite service behind `/resume` and the Web content search) but not the model-facing consumer, so their default requests carry neither the query prompt nor the five schemas; the automation-only ACP composition also mounts neither ([session-search-not-shipped-default](2026-08-02-session-search-not-shipped-default.md)). These compositions also supply the generic timeout and spill policies. The dedicated ACP snapshot fixture mounts the consumer and both policies explicitly, with private local spill storage. Generic tool presentation requires no session-query-specific client plugin. ## Alternatives considered @@ -48,7 +48,7 @@ The consumer is an opt-in plugin. The shipped TUI, Web, and headless composition ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Shipped configuration and the TUI/Web composition tests prove that the model-facing consumer is present on the TUI, Web, and headless surfaces, while assembled ACP request-header snapshots prove that the automation surface omits it by default. A package-owned Loader smoke and dedicated keyless ACP snapshot explicitly mount the consumer with timeout and spill support, pinning its prompt guidance, schemas, and path-independent exact event-read retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Shipped configuration and the TUI/Web composition tests prove that the model-facing consumer is absent from the TUI, Web, and headless surfaces, while assembled ACP request-header snapshots prove that the automation surface omits it by default. A package-owned Loader smoke and dedicated keyless ACP snapshot explicitly mount the consumer with timeout and spill support, pinning its prompt guidance, schemas, and path-independent exact event-read retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index 3ffc142b2a..d8deaba15f 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -36,7 +36,7 @@ Status: implemented ## 宿主组合 -该消费方是一个需显式启用的插件。已交付的 TUI、Web 与无头组合通过共享 base 同时挂载 `ctx.sessionQuery` 和 `@deepseek-ai/dsh-tool-session-query`,因此其默认模型请求包含查询提示词与五个 schema;仅用于自动化的 ACP 组合两者均不挂载。这些组合还提供通用的超时与 spill 策略。专用的 ACP 快照 fixture(测试前置数据)显式挂载该消费方与这两项策略,并使用私有的本地 spill 存储。通用工具表现无需会话查询专用客户端插件。 +该消费方是一个需显式启用的插件。已交付的宿主组合不挂载它:已交付的 TUI、Web 与无头界面保留 `ctx.sessionQuery` 索引(即 `/resume` 与 Web 内容搜索背后的 SQLite 服务),但不挂载面向模型的消费方,因此其默认请求既不携带查询提示词,也不携带五个 schema;仅用于自动化的 ACP 组合也两者均不挂载([session-search-not-shipped-default](2026-08-02-session-search-not-shipped-default.md))。这些组合还提供通用的超时与 spill 策略。专用的 ACP 快照 fixture(测试前置数据)显式挂载该消费方与这两项策略,并使用私有的本地 spill 存储。通用工具表现无需会话查询专用客户端插件。 ## 考虑过的替代方案 @@ -48,7 +48,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。发布配置与 TUI/Web 组合测试证明面向模型的消费方存在于 TUI、Web 与无头界面,而组装后的 ACP 请求头快照证明自动化界面默认不包含它。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。发布配置与 TUI/Web 组合测试证明面向模型的消费方不存在于 TUI、Web 与无头界面,而组装后的 ACP 请求头快照证明自动化界面默认不包含它。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 91d5d6c5ee..1dbc06f8fa 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.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-07-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: 5aaf4798c1297fc273cd715838feb6441ffc0d61 -2026-07-31-even-out-shipped-tool-rosters.zh.md: 79d8dbb8e0aa3cdf462f930ea63a5621dc2d9243 +2026-07-31-even-out-shipped-tool-rosters.md: fe2ed54a70934918b739ac466dc4b0e8f4a93115 +2026-07-31-even-out-shipped-tool-rosters.zh.md: 14cf6e891aa368bcaee8d977fbf5263f36a39dc6 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index 5aaf4798c1..fe2ed54a70 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -12,11 +12,11 @@ The result was a user-visible difference nobody had decided: the same model, ask ## Decision -The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces now assemble the same roster: twenty-five tools on every host, plus `glob` and `grep` when ripgrep is available. +The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster: twenty tools on every host, plus `glob` and `grep` when ripgrep is available. `tool-session-query` joined and then left again — the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) keeps the model-facing consumer opt-in — while the rest of this roster stands. Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. -**This roster decision adds only.** No tool row is removed from either surface, and a catalog comparison finds additions and nothing else. The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md). +**This roster decision added only at the time.** No tool row was removed from either surface when it landed, and a catalog comparison found additions and nothing else. One of those additions, `tool-session-query`, was subsequently removed by the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md). The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md). ### What stays unmounted, and why @@ -62,8 +62,8 @@ Beyond the committed tests, both surfaces were driven against a real key from th ## Consequences -The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. The tests assert the twenty-five unconditional names exactly and require the ripgrep-dependent pair to be either present together or absent together on both sides, so a later change that alters only one surface fails a check instead of shipping quietly. +The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. The tests assert the twenty unconditional names exactly and require the ripgrep-dependent pair to be either present together or absent together on both sides, so a later change that alters only one surface fails a check instead of shipping quietly; the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) is exactly such a later change, and both tests moved with it. -`apps/cli` gains five workspace dependencies: four the shipped tree now mounts, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can. +`apps/cli` gained five workspace dependencies: four the shipped tree mounted, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can. Four remain — the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) removed `@deepseek-ai/dsh-tool-session-query` along with its row. Execution policy stays independent of the roster. The [shared workspace-write decision](2026-07-31-workspace-write-surface-default.md) owns both surfaces' sandboxed executors and default permission; changing that policy does not add or remove a tool. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index 79d8dbb8e0..14cf6e891a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 现在组装同一份清单:每台宿主上都有二十五个工具,ripgrep 可用时再加上 `glob` 和 `grep`。 +那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单:每台宿主上都有二十个工具,ripgrep 可用时再加上 `glob` 和 `grep`。`tool-session-query` 加入后又退出了——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)让面向模型的消费方保持需显式启用——而这份清单的其余部分保持不变。 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 -**本次工具清单决策只做加法。** 两个 surface 均未移除任何工具行,目录对比只会发现新增,别无其他。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。 +**本次工具清单决策当时只做加法。** 落地时两个 surface 均未移除任何工具行,目录对比只发现了新增,别无其他。这些新增中的一项 `tool-session-query` 随后被[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)移除。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。 ### 什么保持不挂,以及为什么 @@ -62,8 +62,8 @@ Status: implemented ## 后果 -同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。测试会精确断言二十五个无条件提供的名称,并要求依赖 ripgrep 的一对工具在两侧要么同时存在、要么同时缺席,因此日后只改一个 surface 都会让检查失败而不是悄悄发出去。 +同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。测试会精确断言二十个无条件提供的名称,并要求依赖 ripgrep 的一对工具在两侧要么同时存在、要么同时缺席,因此日后只改一个 surface 都会让检查失败而不是悄悄发出去;[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)正是这样一次后来的改动,两个测试也随之移动。 -`apps/cli` 增加五个 workspace 依赖:四个是交付树现在挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。 +`apps/cli` 增加了五个 workspace 依赖:四个是交付树当时挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。四个保留了下来——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)把 `@deepseek-ai/dsh-tool-session-query` 连同它的行一起移除了。 执行策略独立于工具清单。[共享 workspace-write 决策](2026-07-31-workspace-write-surface-default.md)拥有两个 surface 的沙箱执行器与默认权限;更改该策略不会增加或移除工具。 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml new file mode 100644 index 0000000000..4a9a16de25 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.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-02-session-search-not-shipped-default.md +2026-08-02-session-search-not-shipped-default.md: ba7299712c0ba3db5e807e928f6f5d98ac917187 +2026-08-02-session-search-not-shipped-default.zh.md: 1678ebfb5514003eabe0221e460c619bab1aa444 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md new file mode 100644 index 0000000000..ba7299712c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md @@ -0,0 +1,25 @@ +# Agent Note: Session search tools are not a shipped default + +Status: implemented + +English | [中文](2026-08-02-session-search-not-shipped-default.zh.md) + +## Problem + +The [shipped-roster decision](2026-07-31-even-out-shipped-tool-rosters.md) made `tool-session-query` a default row of the shared [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), so the shipped TUI and Web surfaces put the five session-search tools (`session_search`, `session_event_search`, `session_trace`, `session_event_trace`, `session_event_read`) in front of the model. That contradicted the [model-facing session-query-tools decision](2026-07-24-model-facing-session-query-tools.md), whose opt-in stance the package README recorded as "shipped host compositions do not mount it by default". The default also shipped a prompt section teaching a prior-work search workflow that no user had asked for. + +## Decision + +The shipped TUI, Web, and headless surfaces no longer mount `@deepseek-ai/dsh-tool-session-query`: the row is removed from the shared `base.cordis.yml`, the now-dangling `disabled` patch in the opt-in [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile goes with it, and the workspace dependency drops from `apps/cli/package.json`. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies. + +The `ctx.sessionQuery` service itself stays mounted. `session-query-sqlite` remains a base row — the TUI's `session-reference` consumes it for `/resume` — and the Web overlay keeps patching it to an in-memory index for the browser content search. Only the model-facing consumer is removed. + +## Alternatives considered + +- **Remove the `session-query-sqlite` index too** — rejected because `/resume` and the Web content-search box consume `ctx.sessionQuery` directly; those are host features, not model tools, and dropping the provider would break them. +- **Keep the row but disable it in each overlay** — rejected because a disabled base row still ships the dependency and invites a one-line re-enable; the recorded opt-in stance wants the consumer absent from shipped surfaces, with the ACP example as the mount reference. +- **Mount it on the TUI only** — rejected because the shared base is one row set for every surface; a surface-specific mount would reintroduce the roster split the shipped-roster decision removed. + +## Consequences + +Both surfaces return to the same twenty unconditional tools (plus `glob`/`grep` under ripgrep), and the five session-search schemas and their prompt section leave the default request. The shipped-composition tests on both surfaces pin the smaller catalog, so re-adding session search as a default touches the same tests. Users who want session search mount the consumer from a personal overlay or the ACP example, adding the dependency where they do. diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md new file mode 100644 index 0000000000..1678ebfb55 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 会话搜索工具不是交付默认项 + +Status: implemented + +[English](2026-08-02-session-search-not-shipped-default.md) | 中文 + +## 问题 + +[交付清单决策](2026-07-31-even-out-shipped-tool-rosters.md)把 `tool-session-query` 设为共享 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml) 的默认行,于是交付的 TUI 与 Web surface 把这五个会话搜索工具(`session_search`、`session_event_search`、`session_trace`、`session_event_trace`、`session_event_read`)呈现给了模型。这与[面向模型的会话查询工具决策](2026-07-24-model-facing-session-query-tools.md)相抵触,该决策持需显式启用的立场,包 README 将其记录为「shipped host compositions do not mount it by default」。这份默认还交付了一个提示词段,向模型讲授一套既往工作搜索工作流,而没有任何用户要求过。 + +## 决策 + +交付的 TUI、Web 与无头 surface 不再挂载 `@deepseek-ai/dsh-tool-session-query`:该行从共享的 `base.cordis.yml` 移除,opt-in 的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile 中那条已悬空的 `disabled` patch 也随之删除,workspace 依赖也从 `apps/cli/package.json` 中移除。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP 示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。 + +`ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`,Web overlay 也继续把它 patch 成内存索引,供浏览器内容搜索使用。被移除的只有面向模型的消费方。 + +## 曾考虑的替代方案 + +- **把 `session-query-sqlite` 索引也一并移除**——否决,因为 `/resume` 和 Web 内容搜索框直接消费 `ctx.sessionQuery`;它们是宿主功能,不是模型工具,移除提供方会破坏它们。 +- **保留该行,但在每个 overlay 中禁用它**——否决,因为一条被禁用的 base 行仍会交付依赖,而且一行就能轻易重新启用;已记录的 opt-in 立场要求消费方不出现在交付的 surface 上,以 ACP 示例作为挂载参考。 +- **只在 TUI 上挂载**——否决,因为共享 base 是所有 surface 共用的一组行;surface 专属挂载会重新引入交付清单决策所消除的清单分裂。 + +## 后果 + +两个 surface 都回到同样的二十个无条件工具(ripgrep 可用时再加上 `glob`/`grep`),五个会话搜索 schema 及其提示词段也一并退出默认请求。两个 surface 上的交付组合测试都固定这份更小的目录,因此把会话搜索重新作为默认加回会触及同样的测试。想要会话搜索的用户从个人 overlay 或 ACP 示例挂载该消费方,并在挂载处添加依赖。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 152b11d8c2..c4deb098c4 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -118,8 +118,6 @@ flowchart LR cfg --> plugin_tui_tool_goal plugin_tui_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] cfg --> plugin_tui_tool_ralph - plugin_tui_tool_session_query["tool-session-query
@deepseek-ai/dsh-tool-session-query"] - cfg --> plugin_tui_tool_session_query plugin_tui_tool_str_replace_editor["tool-str-replace-editor
@deepseek-ai/dsh-tool-str-replace-editor"] cfg --> plugin_tui_tool_str_replace_editor plugin_tui_repeat_tool_guard["repeat-tool-guard
@deepseek-ai/dsh-repeat-tool-guard"] @@ -199,7 +197,6 @@ flowchart LR | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `tool-goal` | `@deepseek-ai/dsh-tool-goal` | | `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | -| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` | | `tool-str-replace-editor` | `@deepseek-ai/dsh-tool-str-replace-editor` | | `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` | | `web` | `@deepseek-ai/dsh-web` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 61d149ad83..df7ed94258 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -316,12 +316,6 @@ subagentProvider: spawn maxRounds: 64 -- id: tool-session-query - name: '@deepseek-ai/dsh-tool-session-query' - config: - maxSearchResults: 100 - searchTimeoutMs: 30000 - - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' config: diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index ef03b23fca..d025aef7f4 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -45,9 +45,6 @@ - id: tool-ralph disabled: true -- id: tool-session-query - disabled: true - - id: tool-str-replace-editor disabled: true diff --git a/apps/cli/package.json b/apps/cli/package.json index e6d853db37..58368f04d9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -117,7 +117,6 @@ "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", - "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", diff --git a/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts index aa1a803c85..b6c5adba06 100644 --- a/apps/cli/tests/shipped-composition.e2e.ts +++ b/apps/cli/tests/shipped-composition.e2e.ts @@ -35,11 +35,6 @@ const EXPECTED_TUI_TOOLS = [ 'get_goal', 'ralph', 'read', - 'session_event_read', - 'session_event_search', - 'session_event_trace', - 'session_search', - 'session_trace', 'skill', 'str_replace_editor', 'subagent', diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 671cc84906..3f5ce4e4fa 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -30,11 +30,6 @@ const EXPECTED_TOOLS = [ 'get_goal', 'ralph', 'read', - 'session_event_read', - 'session_event_search', - 'session_event_trace', - 'session_search', - 'session_trace', 'skill', 'str_replace_editor', 'subagent', diff --git a/packages/session-query/tool-session-query/README.i18n.yaml b/packages/session-query/tool-session-query/README.i18n.yaml index 5df258e899..e86449af9c 100644 --- a/packages/session-query/tool-session-query/README.i18n.yaml +++ b/packages/session-query/tool-session-query/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/session-query/tool-session-query/README.md -README.md: 9a70f29d7c39af816c9efcf479ad129f0148883c -README.zh.md: 55717aef20d53686cce963d09b2e41350d274a75 +README.md: d973daf1124c4be05f7335b18661d431d45be39f +README.zh.md: b27d79a905a029d3750f24573e3c32785a314015 diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md index 9a70f29d7c..d973daf112 100644 --- a/packages/session-query/tool-session-query/README.md +++ b/packages/session-query/tool-session-query/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; the shipped TUI, Web, and headless compositions mount it by default, while ACP does not. +Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; shipped host compositions do not mount it by default. ## Configuration diff --git a/packages/session-query/tool-session-query/README.zh.md b/packages/session-query/tool-session-query/README.zh.md index 55717aef20..b27d79a905 100644 --- a/packages/session-query/tool-session-query/README.zh.md +++ b/packages/session-query/tool-session-query/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包(package)只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已交付的 TUI、Web 与无头组合默认挂载它,而 ACP(Agent Client Protocol)不挂载。 +位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包(package)只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已发布的宿主组合默认不挂载它。 ## 配置 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ec61249d7..d9a8b7f4a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -432,9 +432,6 @@ importers: '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph - '@deepseek-ai/dsh-tool-session-query': - specifier: workspace:^ - version: link:../../packages/session-query/tool-session-query '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill From b5731dfdaf81d7b40796ca20a543d75a164ee318 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 22 Jul 2026 16:17:53 +0800 Subject: [PATCH 025/114] docs: propose continuable background subagents --- ...continuable-background-subagents.i18n.yaml | 6 + ...-07-21-continuable-background-subagents.md | 148 ++++++++++++++++++ ...-21-continuable-background-subagents.zh.md | 148 ++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 .agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md create mode 100644 .agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml new file mode 100644 index 0000000000..ef603b1543 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-21-continuable-background-subagents.md: f0fb441cab87010f544d9be7036518b6f3e41c77 +2026-07-21-continuable-background-subagents.zh.md: 0dfb5fe8837b51c9a0220fb32f9b1340202e8ae6 diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md new file mode 100644 index 0000000000..f0fb441cab --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md @@ -0,0 +1,148 @@ +# Agent Note: Continuable background subagents + +Status: proposed + +English | [中文](2026-07-21-continuable-background-subagents.zh.md) + +## Problem + +The subagent tool treats each delegation as one owned `SubagentRun`: foreground calls and background Tasks collect the result and then dispose the run. Disposal bounds the number of live child Agents and releases their scoped services, listeners, and provider resources. The persisted child session may survive, but the parent has no durable catalog or tool path for discovering that child and starting another turn on it. + +A Task, a run, and a child session have different lifetimes. A Task represents one background turn and has one terminal result. A `SubagentRun` owns one activation of a child. A persisted child session may contain many turns initiated by the parent or a human. Continuation must preserve per-run disposal rather than retain every historical child Agent in memory. + +## Proposal + +A continuable background subagent is a durable child session with a series of Task-backed activations. The child session id, transcript, lineage, and declared composition survive in persistence. Each initial or resumed activation creates a fresh Task, `AgentHandle`, and `SubagentRun`, drives one turn, collects its result, and disposes the run before the Task becomes terminal. + +The Task's result and cancellation boundary belong to the child activation, not to whichever caller supplied its first message. Task access is authorized by the parent session id, while the Task registry retains the exact live parent Agent instance for notification and teardown. Parent and human messages therefore share one activation result while the parent remains its runtime owner: + +```text +durable child Session + activation 1: Task 1 -> SubagentRun -> AgentHandle -> dispose + activation 2: Task 2 -> SubagentRun -> AgentHandle -> dispose + activation 3: Task 3 -> SubagentRun -> AgentHandle -> dispose +``` + +Foreground delegation keeps its current one-shot behavior. The first continuable implementation covers background in-process spawn and fork children. A provider must support persisted cold resume before its children are advertised as continuable; ACP children remain one-shot until the deferred ACP continuation work below is complete. + +The low-level `ctx.subagents` seam remains collection-, Task-, and persistence-agnostic. It registers providers, validates and dispatches `start` or `resume`, observes run lifecycle, and returns holder-owned runs. A separate `SubagentControlService` in `@deepseek-ai/dsh-subagent-control` owns stable continuable-child ids, descriptor persistence and lookup by known child id, Task-backed activation, and message routing. The provider-bound `@deepseek-ai/dsh-tool-subagent` plugin and human-facing adapters call that control service for continuable background work; foreground one-shot delegation still calls `ctx.subagents.start()` directly. The globally named model tool is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. Parent-to-child enumeration and `list_agents` are specified separately by the [durable subagent catalog](2026-07-22-durable-subagent-catalog-and-list-agents.md). + +### Task and cancellation ownership + +The initial background delegation asks the control service to start the child and register its Task. Task settlement awaits the result, calls `run.dispose()`, and only then records the `TaskOutcome`; `task_kill` aborts the active run, whose settlement path still disposes it. A terminal Task therefore leaves the durable child session but no live child Agent. + +Every later turn creates another Task. Its producer resources cover only that activation, never the child session. It reaches one terminal status, has one result, and is never reopened. The exact live parent Agent remains the Task registry owner: disposing that instance cancels, awaits, and removes its Tasks. Task APIs authorize a caller whose session id matches that owner, but a same-id replacement does not become the notification or teardown target. This preserves the existing `settleRun()` contract and bounds Task-owned live children by concurrent work rather than historical session count. + +Opening a child session in a human-facing adapter reads its persisted transcript and does not resume an Agent merely to display it. Human input starts or joins the same Task-backed activation used by parent input through the control service. A human-started Task retains the exact currently loaded parent Agent as its notification target, and `task_output` remains the single result path. The existing completion listener injects at most one unsolicited notice while the Task is unreported; `kill`, a terminal read, or a terminal wait may mark it reported and suppress that notice. The first version therefore permits human interaction only while that parent instance remains live. A user-owned conversation that may outlive the parent and explicitly merge a conclusion back belongs to [interactive side sessions](2026-07-08-interactive-side-sessions.md), not this Task-owned lifecycle. + +`TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations. + +Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as `killed`; the messages do not have independent results or cancellation rights. Independent cancellation requires a later message to start a separate turn instead of steering the current one. + +A cold-resume Task creates its activation-owned `AbortController` before descriptor lookup or any provider await and passes that signal through `SubagentControlService.resume()`, `SubagentService.resume()`, and `SubagentProvider.resume?()`. A persistence call that has no signal need not stop its underlying I/O, but the control service rechecks cancellation after every such await and cannot begin or publish child work afterward. Before Agent publication, abort makes the provider reject only after its creation transaction has rolled back and reached quiescence. After publication, the provider closes the creation-signal handoff race, attaches the same signal to the live run before returning it, and cancellation stops the child turn. `task_kill` and exact-owner disposal use this path even when provider resume has not returned a `SubagentRun`; Task settlement waits for rollback or run disposal and records `killed` only after the activation is quiescent. + +### Active run association + +The control service keeps a process-local association from child session id to its current Task and, after provider publication, its run. It installs the Task association before awaiting provider start or resume, fills in the returned run, and removes the association only after run disposal and Task terminal publication. This association exists only so parent and human senders can find the same activation; it is not a durable catalog, public `ManagedSubagent`, admission reservation, or run-state machine. + +For a continuable initial activation, the control service allocates the stable child session id before Task creation and passes it in the resolved provider start request; in-process spawn and fork publish that exact id instead of allocating one internally. The background tool acknowledgement exposes both identities as `started subagent as task `. The child id names the durable conversation across activations, while the Task id names only the current activation. A failed initial Task or a process exit before the first child flush can leave an **unmaterialized child**: the caller holds a child id without a durable header and descriptor. Later by-id control operations report that id as unavailable, and durable enumeration omits it. + +The first version admits every continuable child turn through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the control service synchronously compares its association with `ctx.agents.get(childId)`. A registry Agent with no association, or a registry Agent different from the associated `run.localAgent`, is an ownership conflict: the control service fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary. + +Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability by synchronously requiring `AgentStatus.running` before calling `Agent.steer()`; the check and call contain no asynchronous boundary. Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict check, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. + +The first version does not serialize two callers that concurrently observe a stopped child, nor does it model a separate settling phase between result production and disposal. Concurrent cold-resume attempts may both create Tasks, but the Agent registry permits only one same-session Agent to publish; a losing Task fails and its message is not delivered. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction. + +Atomic process-local admission is on hold. The smallest follow-up would synchronously reserve the child before awaiting resume, conceptually with `Map>`; later callers would await the same publication promise and then use strict live delivery. This would close duplicate cold resume without adding a public `ManagedSubagent` or explicit `starting`/`running`/`settling` protocol. + +### Model-facing `send_message` + +The model receives one `send_message(subagent_id, message)` tool backed by `SubagentControlService.sendMessage()`. The control operation owns steer-or-resume orchestration and is distinct from the run's `SubagentRun.steer?()`, which only delivers to an already active run. The tool performs no lifecycle routing of its own. It lives in a separately loaded `@deepseek-ai/dsh-tool-subagent-control` package so provider-bound `@deepseek-ai/dsh-tool-subagent` instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools. + +- If the child has a running Task and live-steering capability, the service calls `run.steer(message)` and returns the existing Task id; it creates no Task of its own. +- If the child has no running Task, `send_message` creates a fresh Task, cold-resumes the durable session with the message, and returns the new Task id. +- If the active provider cannot accept live delivery, strict steering loses a race with Task settlement, or a live child exists outside the Task association, `send_message` fails rather than silently starting, resuming, or adopting an untracked turn. + +The service result identifies the route as `steered` with the existing Task id or `started` with the new Task id. Failure is explicit and says that the message was not delivered. The model-facing tool renders these distinctions so timing-dependent routing is observable to the caller. + +A delivered message has no independent result: its effect is reflected in the current Task's eventual result. A started follow-up has the fresh Task's result and existing `task_output` read path. The subagent layer adds no second completion injection. + +Human input uses the same control operation. The UI may display the child transcript and current Task state, while cancellation calls the Task service with the loaded parent as caller. Tool schema and UI adapters are consumers of one control-service contract rather than separate execution paths. + +### Durable child handle and cold resume + +The control service snapshots every descriptor input with [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution appends one model-hidden `subagent/descriptor` event after the initial child `turn/start` and before its first request; it carries no `surfaceOp`, remains outside model history, and reaches persistence with that turn's flush. The append-only log retains this non-surface event when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor and its header identifies the caller as the direct parent. + +The versioned descriptor contains the subagent provider name, resolved child `agentOptions.provider` and `agentOptions.model`, and optional `persona` and `toolFilter`. It does not snapshot the merge-extensible `AgentOptions` object: unrelated extension values cannot make continuation fail merely because they are not JSON. It deliberately omits `subagentDepth`; cold resume relies on the persisted header's `delegationDepth` rather than reconstructing depth from the descriptor. `outputSchema` belongs to one activation's result contract rather than durable child composition. The child header remains authoritative for the child id, `cwd`, `parentSession`, `seedLength`, and `delegationDepth`, while the persisted child transcript owns the fork seed and subsequent history. [`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) takes the maximum of header and runtime values, so reconstructed runtime options may deepen the persisted value but never lower it and a resumed child cannot regain a top-level delegation budget. + +Cold resume cannot depend on an optional method of the old `SubagentRun`, because that run has been disposed and is not retained across process restart. This proposal removes `SubagentRun.resume?()`: a run represents one disposable activation and exposes only activation-scoped operations. It also renames the existing `SubagentRun.sendMessage?()` capability to `SubagentRun.steer?()` so its strict live-only contract cannot be confused with service orchestration or the model-facing tool. + +`SubagentControlService.resume()` loads the known child session, folds its descriptor, authorizes the persisted `parentSession`, and creates the Task. It passes a fully resolved request, including the Task-owned cancellation signal, to the low-level `SubagentService.resume(provider, request)`, whose only responsibility is capability-checked provider dispatch and the ordinary run lifecycle observation used by `start`. The selected `SubagentProvider.resume?()` owns transport-specific reconstruction and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag is added. `SubagentControlService.sendMessage()` chooses between the associated run's `steer?()` operation and this cold-resume path. Neither the low-level service nor a provider enumerates durable children or associates Tasks. + +The background tool validates and snapshots descriptor inputs before calling `TaskService.start()`. A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. Child creation, first-turn persistence, or descriptor persistence failure disposes any published run and settles the already-created Task as `failed`; the model observes that failure through the ordinary Task completion or `task_output` path. The first implementation reconstructs in-process spawn and fork composition under the currently loaded parent scope. A fork resume loads the child's own persisted transcript, which already contains the completed-turn prefix captured at initial creation; it never forks the parent's newer history again. Resuming a parent does not eagerly resume its children. + +TODO (ACP continuation): persist the remote ACP session id as provider-specific descriptor data and implement `AcpProvider.resume?()` as spawn, initialize, `loadSession`, then prompt. The initial ACP run must verify `initialize.agentCapabilities.loadSession`, and every resumed process must use the same durable backend; replayed history from `loadSession` must not be collected as the new activation's output. Because ACP load support is negotiated per child rather than established solely by the provider method's presence, this follow-up must also define how a start result advertises child-specific continuation before ACP children enter the durable catalog. + +### Result and notification ownership + +Every continuable child activation has exactly one Task and one `TaskOutcome`, regardless of whether the parent or a human supplied the first message. The generic Task reporting contract may inject at most one unsolicited completion notice to the retained parent owner while the Task is unreported; reads, waits, and cancellation may suppress it. Running delivery joins that activation and creates neither a second Task nor a second result. The child transcript remains the human-facing detailed record; Task output remains the parent-facing final result. + +Task records and active-run associations are process-local. Persistence makes the child session resumable after restart, but does not recover an interrupted Task, its result, or its notification. Durable Task recovery is a separate concern. + +### Implementation boundary + +One implementation PR delivers this proposal: stable child-id allocation and provider handoff, the child-session descriptor event, `SubagentControlService`, in-process provider cold resume, existing background-delegation routing, strict spawn/fork steering, active-run association, human message routing, and the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package with its `send_message` tool. Parent-to-child enumeration and `list_agents` consume this durable child-handle contract but remain a separate feature and PR. ACP continuation is a separate provider follow-up after the child-specific advertisement contract above is resolved. + +## Alternatives considered + +**Retain every background child after Task settlement.** This is the Codex-style resident-session model: follow-up delivery is cheap, but historical children retain Agent scopes, session memory, listeners, and provider resources until an explicit residency limit or eviction policy removes them. Per-activation disposal uses persistence as the continuation boundary and preserves the current resource bound. + +**Let human turns run without Tasks.** A parent message joining such a turn has no Task result or completion notice, and UI cancellation has unclear effects on the parent's contribution. Giving every activation one Task makes completion and cancellation properties of the child turn rather than its initiating caller. + +**Keep one Task for the lifetime of a child session.** A terminal Task cannot naturally become running again, and one result cannot represent multiple turns. Fresh activation-scoped Tasks preserve the generic Task contract. + +**Create a Task for every message.** Steering joins an existing turn and has no independent final result, so a Task created for steering would duplicate the active Task or report a result it does not own. Only a message that starts an activation creates a Task. + +**Split `send_message` and `follow_up`.** Separate strict operations expose an implementation-state distinction to the model without removing stopped-child races. One operation follows the Claude Code model: deliver to running work or resume a new Task-backed lifecycle. + +**Keep `resume?()` on the disposed run.** Retaining a disposed `SubagentRun` only to call `resume()` makes the old run double as a durable child handle and cannot reconstruct that object after restart. Service dispatch plus provider reconstruction makes the persistence boundary explicit. + +**Put control orchestration on `SubagentService`.** This would let one service look up descriptors, associate Tasks, and dispatch providers, but would make the collection-agnostic provider seam depend on one consumer's persistence and Task policy. A separate control service keeps start/resume transport reusable by foreground and non-Task consumers while giving tools and UI one orchestration path. + +**Add explicit activation phases.** Public `starting`/`running`/`settling` states could describe admission and cleanup precisely, but would add a lifecycle protocol that the first implementation does not otherwise need. The on-hold promise reservation closes duplicate process-local cold resume without exposing those phases. + +## Acceptance criteria + +- Initial and resumed continuable activations create a fresh Task and dispose their run before that Task becomes terminal. +- Opening a persisted child for display creates no Agent activation; human input under a loaded parent starts or joins a Task-backed activation. +- Human and parent messages delivered to one running activation share its Task result and cancellation outcome. +- Cancelling a human-started activation aborts and disposes its run and settles the Task as `killed`; its completion notice follows the generic at-most-one reporting contract and may be suppressed when the Task is already reported. +- A cold-resume Task owns its AbortSignal before descriptor lookup; cancellation during lookup or provider resume prevents later publication or cancels the published run, and Task settlement waits for rollback or disposal quiescence before reporting `killed`. +- A human-facing adapter attaches a Task control surface before accepting child input; absence of a surface fails clearly instead of starting untracked work. +- `send_message` delivers to a running child without creating a Task and cold-resumes a stopped child into a fresh Task-backed activation. +- `send_message` reports whether it `steered` an existing Task or `started` a new Task, including the relevant Task id, and reports a failure as not delivered. +- Initial continuable delegation allocates its child id before Task creation, passes that id through provider publication, and returns both the stable child id and activation Task id to the model. +- Spawn and fork implement strict `SubagentRun.steer` behavior with no asynchronous boundary between the running check and `Agent.steer()`; live delivery cannot fall back to an untracked Agent turn. +- If strict steering loses a race with Task settlement, `send_message` reports the message as not delivered and does not cold-resume within that call. +- `SubagentRun` has no cold-resume operation; `SubagentControlService.sendMessage()` dispatches active delivery to `run.steer?()` and inactive delivery through low-level `SubagentService.resume()` to `SubagentProvider.resume?()`. +- The `SubagentRun.sendMessage?()` to `steer?()` rename and the background activation route update the seam module JSDoc, package READMEs, core-data-structures catalog, and `tool-subagent` `settleRun` ownership documentation and tests in the same PR. +- `SubagentService` remains unaware of Tasks and durable descriptors; `SubagentControlService` owns continuable activation, authorization, descriptor lookup by known child id, and Task/run association for tool and UI consumers. +- Every supported continuable child turn installs its Task association before provider awaits and retains it through run disposal; by-id routing rejects a live `ctx.agents.get(childId)` unless the association exists and its `run.localAgent` is that exact Agent. +- A known persisted child id can be authorized and lazily reconstructed after parent resume with equivalent declared composition under the resumed parent's scope; fork resume uses only the child's persisted transcript and never re-forks current parent history. +- Descriptor inputs are snapshotted before Task creation; a versioned model-hidden descriptor event is turn-enclosed in the child session, excluded from the surface, retained across compaction, and folded only after the child header passes direct-parent authorization. The descriptor omits `subagentDepth`, and resumed depth uses the persisted header as its monotone floor. +- Invalid descriptor JSON rejects the tool without creating a Task, while asynchronous child or descriptor persistence failure disposes the run and settles the returned Task as `failed`. +- Provider-bound delegation tools remain in `@deepseek-ai/dsh-tool-subagent`; the globally named `send_message` tool registers once from `@deepseek-ai/dsh-tool-subagent-control`. +- Each activation produces one Task result and at most one unsolicited existing Task completion notice; reads, waits, or cancellation may suppress that notice, and steering and the subagent layer add no duplicate notification. +- Tests document that concurrent stopped-child admission is not atomic: one same-session publication wins, a losing Task fails, and the losing message is not reported as delivered. +- Keyless package tests cover Task ownership, disposal ordering, human start and cancellation, running delivery, cold follow-up, descriptor rejection and rollback, known-id reconstruction, scope reconstruction, and terminal cleanup. Model-visible tool and transcript changes have runnable snapshot coverage. + +## Risks + +- Every follow-up after settlement pays persistence load and scoped setup cost. Continuable creation fails clearly when persistence is unavailable or the stored composition cannot be reconstructed. +- Two callers may concurrently observe a stopped child and start competing resumes. The Agent registry prevents duplicate same-session publication, but a losing Task fails and its message is not delivered. A message may also race cancellation, terminal status publication, or run disposal. The first version does not claim atomic or exactly-once admission; the on-hold process-local promise reservation can close duplicate cold resume without requiring a public lifecycle state machine. +- Driving a continuable child through the ordinary Agent API bypasses its Task association. The control service rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through `SubagentControlService.sendMessage()`. +- The active-run association coordinates only one runtime. Concurrent resume from multiple processes is not serialized; that deployment requires a persistence-level lease or compare-and-set operation. +- Human interaction requires the exact parent Agent instance to remain live because owner disposal cancels and removes its Tasks. It also requires an attached Task control surface. Standalone child interaction requires a future separation between Task access ownership and durable notification targeting. +- The background tool returns child and Task ids before child publication and descriptor durability. Startup failure, persistence failure, or process exit before the first child flush may leave an unmaterialized child id; by-id control reports it as unavailable and durable enumeration omits it rather than retroactively changing the tool result. +- Persisting explicit composition fields in the child log makes their lossless-JSON and compatibility contract part of resume. Later support for another composition input requires a deliberate descriptor-version change rather than implicitly persisting merge-extensible `AgentOptions` fields. +- Task records and active-run associations are process-local even though child sessions are durable. Restart recovers the session, not in-flight work or its Task notification. diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md new file mode 100644 index 0000000000..0dfb5fe883 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md @@ -0,0 +1,148 @@ +# Agent Note: 可继续的后台 subagent + +Status: proposed + +[English](2026-07-21-continuable-background-subagents.md) | 中文 + +## 问题 + +subagent 工具将每次委派视为一个独占的 `SubagentRun`:前台调用和后台 Task 收集结果后 dispose(资源释放)该 run。这种所有权关系能够限制存活 child agent(智能体)的数量,并释放其作用域服务、监听器及提供方资源。持久化的 child 会话可能继续存在,但 parent 缺少持久化目录和工具路径,无法发现该 child 并为其启动另一轮次。 + +Task、run 和 child 会话具有不同的生命周期。一个 Task 表示一轮后台执行,并且只有一个终态结果。一个 `SubagentRun` 拥有 child 的一次激活。一个持久化 child 会话可以包含多个由 parent 或用户发起的轮次。继续执行必须保留逐 run dispose 的约定,而不能把所有历史 child agent 都留在内存中。 + +## 提案 + +一个可继续的后台 subagent,是由一系列 Task 支撑的短期激活共同组成的持久化 child 会话。child session id、transcript(文本记录)、谱系及声明的组合配置均保留在持久化存储中。每次初始激活或恢复激活都会创建新的 Task、`AgentHandle` 和 `SubagentRun`,驱动一个轮次、收集结果,并在 Task 进入终态前 dispose 该 run。 + +Task 的结果和取消边界属于 child 激活,不属于为该激活提供第一条消息的调用方。Task 访问根据 parent session id 授权,而 Task 注册表仍保留当前存活的精确 parent Agent 实例,用于通知与资源清理。因此,只要 parent 仍是运行时 owner,parent 消息和用户消息便会共享同一个激活结果: + +```text +durable child Session + activation 1: Task 1 -> SubagentRun -> AgentHandle -> dispose + activation 2: Task 2 -> SubagentRun -> AgentHandle -> dispose + activation 3: Task 3 -> SubagentRun -> AgentHandle -> dispose +``` + +前台委派保持当前的一次性行为。第一版可继续实现覆盖进程内 spawn 和 fork child。提供方只有支持从持久化存储恢复后,才能将其 child 标记为可继续;在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。 + +底层 `ctx.subagents` seam 不感知 child 集合、Task 与持久化。它注册提供方,校验并分发 `start` 或 `resume`,观察 run 生命周期,并返回由持有方负责的 run。`@deepseek-ai/dsh-subagent-control` 中单独的 `SubagentControlService` 负责管理可继续 child 的稳定 id、持久化描述符并按已知 child id 查找、由 Task 支撑的激活,以及消息路由。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器通过该控制服务处理可继续后台工作;前台一次性委派仍直接调用 `ctx.subagents.start()`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。parent 到 child 的枚举与 `list_agents` 由单独的[持久化 subagent 目录](2026-07-22-durable-subagent-catalog-and-list-agents.md)规定。 + +### Task 与取消的所有权 + +初始后台委派请求控制服务启动 child 并注册其 Task。Task 结算流程等待结果,调用 `run.dispose()`,然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。 + +后续每个轮次都会创建另一个 Task。该轮 producer 持有的执行资源仅服务于这次激活,不属于 child 会话。它只会到达一次终态、只产生一个结果,也不会重新打开。Task 注册表中当前注册的那个存活 parent agent 实例仍是其 owner:dispose 该实例会取消、等待并移除其 Task。Task API 会授权 session id 与该 owner 匹配的调用方,但 id 相同的替代实例不会成为通知或资源清理目标。这一设计保留现有 `settleRun()` 契约,并使 Task 所拥有的存活 child 数量受并发工作量限制,而不是随历史会话数量增长。 + +用户界面适配器打开 child 会话时,只读取持久化 transcript,不会仅为展示而恢复 agent。用户输入通过控制服务,启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。第一版仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。 + +如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 + +取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 + +从持久化存储恢复的 Task 会在查找描述符或等待任何提供方操作之前,创建由本次激活持有的 `AbortController`,并通过 `SubagentControlService.resume()`、`SubagentService.resume()` 和 `SubagentProvider.resume?()` 逐层传递其信号。对于不接受信号的持久化调用,可以让底层 I/O 执行完毕;但控制服务必须在每次这类 await 返回后重新检查取消状态,如已取消,之后不得开始或发布任何 child 工作。在 Agent 发布前收到中止信号时,提供方必须先回滚其创建事务并达到完全停稳状态,然后才让恢复调用以拒绝结束。Agent 发布后,提供方必须消除创建期间移交取消信号时的竞态,在返回前将同一信号附加到存活 run;之后取消会停止 child 轮次。即使提供方的恢复调用尚未返回 `SubagentRun`,`task_kill` 与对确切 owner 实例的 dispose 仍通过这条路径生效。Task 结算会等待回滚或 run dispose 完成,只有在激活完全停稳后才记录 `killed`。 + +### 活跃 run 关联 + +控制服务在进程内维护 child session id 到当前 Task 的关联,并在提供方发布后将 run 填入该关联。它会在等待提供方 start 或 resume 之前安装 Task 关联,填入返回的 run,并且只在 run dispose 完成且 Task 终态发布后才移除该关联。该关联只用于让 parent 发送方和用户发送方找到同一次激活;它不是持久化 child 目录、公开的 `ManagedSubagent`、准入预留或 run 状态机。 + +对于可继续 child 的初始激活,控制服务会在创建 Task 前分配稳定的 child session id,并通过已完全解析的提供方启动请求传递该 id;进程内 spawn 和 fork 会发布这一确切 id,而不是在内部另行分配。后台工具的确认消息会同时公开两种标识,格式为 `started subagent as task `。child id 在多次激活中始终指代同一个持久化对话,Task id 则只指代当前激活。初始 Task 失败,或进程在 child 首次 flush 之前退出,都可能留下一个 **unmaterialized child**:调用方持有 child id,但不存在持久化 header 和描述符。后续按 id 的控制操作会报告该 id 不可用,持久化枚举也不会列出它。 + +第一版要求每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,控制服务会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:控制服务会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。 + +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 通过以下方式实现该功能:调用 `Agent.steer()` 前同步要求 `AgentStatus.running`,检查与调用之间不存在异步边界。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 + +第一版不会串行化两个同时观察到 child 已停止的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。并发的 cold resume 尝试可能都会创建 Task,但 agent 注册表只允许一个相同会话的 agent 完成发布;失败的 Task 不会送达其消息。发送也可能因与启动、取消、完成或清理发生竞态而失败。本提案明确接受这些限制,不为此引入更大的生命周期抽象。 + +原子的进程内准入暂缓实现。最小的后续方案是在等待 resume 之前同步预留 child,概念上使用 `Map>`;后续调用方等待同一个发布 promise,再使用严格的在线消息功能。这样无需添加公开的 `ManagedSubagent` 或显式 `starting`/`running`/`settling` 协议,即可消除重复的 cold resume。 + +### 面向模型的 `send_message` + +模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 + +- 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message)` 并返回现有 task id;它不会创建新 Task。 +- 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 +- 如果活跃提供方无法接收在线消息、严格 steering 在与 Task 结算的竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。 + +服务结果将路由标识为 `steered` 并携带现有 task id,或标识为 `started` 并携带新的 task id。失败结果会明确说明消息未送达。面向模型的工具会呈现这些差异,让调用方能够观察由时序决定的实际路由。 + +发送到现有 run 的消息没有独立结果,其效果体现在当前 Task 的最终结果中。启动的后续轮次具有新 Task 的结果,并使用现有 `task_output` 读取路径。subagent 层不会再注入第二份完成通知。 + +用户输入使用同一个控制操作。UI 可以展示 child transcript 和当前 Task 状态,取消操作则以已加载 parent 作为调用方访问 Task 服务。工具 schema 与 UI 适配器消费同一个控制服务契约,不建立彼此独立的执行路径。 + +### 持久化 child handle 与从持久化存储恢复 + +控制服务在创建 Task 前,通过 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution 会在 child 初始 `turn/start` 之后、首次请求之前追加一个对模型隐藏的 `subagent/descriptor` 事件。该事件不携带 `surfaceOp`,不进入模型历史,并随该轮次的 flush 一并进入持久化存储。当压缩替换 surface 历史时,仅追加日志仍保留这个不属于 surface 的事件。只有在加载已知 child id 对应的 child 会话后能得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 + +版本化描述符包含 subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果契约,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`seedLength` 和 `delegationDepth` 的权威信息,持久化 child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。 + +从持久化存储恢复不能依赖旧 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。本提案删除 `SubagentRun.resume?()`:run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。本提案还将现有 `SubagentRun.sendMessage?()` 功能改名为 `SubagentRun.steer?()`,以免其严格的仅在线契约与服务编排或面向模型的工具混淆。 + +`SubagentControlService.resume()` 会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并创建 Task。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建,并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。 + +后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。child 创建、首轮持久化或描述符持久化失败时,系统会 dispose 所有已发布的 run,并将已经创建的 Task 结算为 `failed`;模型通过普通 Task 完成通知或 `task_output` 路径观察该失败。第一版会在当前已加载的 parent 作用域下重建进程内 spawn 和 fork 组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 + +TODO(ACP 继续执行):将远端 ACP session id 作为提供方专用描述符数据持久化,并实现 `AcpProvider.resume?()`,依次执行 spawn、initialize、`loadSession` 和 prompt。初始 ACP run 必须检查 `initialize.agentCapabilities.loadSession`,恢复后的每个进程必须使用同一个持久化后端;`loadSession` 回放的历史消息不得计入新激活的输出。由于 ACP 的加载支持是按 child 协商的,不能仅根据提供方是否存在该方法来确定,因此该后续工作还必须定义 start 结果如何声明单个 child 支持继续执行,之后才能将 ACP child 写入持久化目录。 + +### 结果与通知所有权 + +每次可继续 child 激活都恰好拥有一个 Task 和一个 `TaskOutcome`,无论第一条消息由 parent 还是用户提供。只要 Task 尚未标记为已报告,通用 Task 报告契约最多会向保留的 parent owner 注入一条主动完成通知;读取、等待和取消都可能抑制该通知。发送到运行中激活的消息会加入该激活,不会创建第二个 Task 或第二份结果。child transcript 是面向用户的详细记录;Task 输出是面向 parent 的最终结果。 + +Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可在重启后恢复,但不会恢复中断的 Task、其结果或通知。持久化 Task 恢复属于另一个问题。 + +### 实现边界 + +一个实现 PR 会交付本提案:稳定 child id 的分配与提供方交接、child 会话描述符事件、`SubagentControlService`、进程内提供方从持久化存储恢复、现有后台委派路由、严格的 spawn/fork steering、活跃 run 关联、用户消息路由,以及单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包及其 `send_message` 工具。parent 到 child 的枚举与 `list_agents` 使用这份持久化 child handle 契约,但仍是单独的功能和 PR。解决上述按 child 声明支持的契约后,再通过单独的提供方改动支持 ACP 继续执行。 + +## 已考虑的替代方案 + +**在 Task 结算后保留所有后台 child。** 这是 Codex 风格的常驻会话模型:发送后续消息成本较低,但历史 child 会持续占用 agent 作用域、会话内存、监听器和提供方资源,直至显式常驻数量上限或淘汰策略将其移除。逐激活 dispose 使用持久化作为继续执行边界,同时保留当前的资源上限。 + +**允许用户轮次不使用 Task。** parent 消息加入此类轮次后,没有对应的 Task 结果或完成通知;UI 取消对 parent 所发消息的影响也不明确。让每次激活都拥有一个 Task,可使完成与取消成为 child 轮次的属性,而不是初始调用方的属性。 + +**在 child 会话整个生命周期内复用一个 Task。** 终态 Task 无法自然地再次进入运行状态,一个结果也无法表示多个轮次。每次激活创建新 Task 可以保留通用 Task 契约。 + +**为每条消息创建 Task。** 发送到现有 run 的消息会加入已有轮次,不产生独立的最终结果;为这类消息创建 Task,会重复当前 Task,或报告一个它并不拥有的结果。只有启动新激活的消息才会创建 Task。 + +**拆分 `send_message` 与 `follow_up`。** 两个严格操作会向模型暴露实现状态差异,却无法消除 child 已停止时的竞态。单一操作采用 Claude Code 模型:向运行中的工作发送消息,或恢复一个由新 Task 支撑的生命周期。 + +**在已 dispose 的 run 上保留 `resume?()`。** 如果仅为调用 `resume()` 而保留已 dispose 的 `SubagentRun`,旧 run 会同时充当持久化 child handle,而且进程重启后无法重建该对象。由服务分发、提供方重建,可明确表达持久化边界。 + +**将控制编排放在 `SubagentService` 上。** 这样一个服务就能查找描述符、关联 Task 并分发提供方,但会迫使不感知集合的提供方 seam 依赖某个消费方的持久化与 Task 策略。单独的控制服务让前台及不使用 Task 的消费方可以复用 start/resume 传输,同时为工具和 UI 提供统一的编排路径。 + +**增加显式激活阶段。** 公开的 `starting`/`running`/`settling` 状态可以准确描述准入和清理,但会引入第一版实现并不需要的生命周期协议。暂缓实现的 promise 预留无需暴露这些阶段,即可消除进程内重复的 cold resume。 + +## 验收标准 + +- 初始及恢复后的可继续激活都会创建新 Task,并在该 Task 进入终态前 dispose 对应 run。 +- 打开持久化 child 仅用于展示时,不会创建 agent 激活;在 parent 已加载时,用户输入会启动或加入一个由 Task 支撑的激活。 +- 用户消息和 parent 消息发送到同一个运行中激活后,共享其 Task 结果和取消结果。 +- 取消用户启动的激活会中止并 dispose 对应 run,将 Task 结算为 `killed`;其完成通知遵循通用的至多一次报告契约,并且在 Task 已标记为已报告时可能被抑制。 +- 从持久化存储恢复的 Task 在描述符查找前就持有其 AbortSignal;查找描述符或执行提供方恢复期间发生取消时,系统不得在之后发布 run,若 run 已发布则会取消它。Task 只有在回滚或 dispose 完成、激活完全停稳后,才结算为 `killed`。 +- 用户界面适配器在接受 child 输入前会附加 Task 控制面;缺少控制面时明确失败,而不会启动未受跟踪的工作。 +- `send_message` 向运行中的 child 发送消息时不会创建 Task;向已停止的 child 发送消息时,会从持久化存储恢复并创建新的 Task 激活。 +- `send_message` 会以 `steered` 报告消息已加入现有 Task,或以 `started` 报告已启动新 Task,并携带相应 task id;失败时会报告消息未送达。 +- 初始可继续委派在创建 Task 前分配 child id,通过提供方发布路径传递该 id,并向模型返回稳定的 child id 与当前激活的 Task id。 +- spawn 和 fork 实现严格的 `SubagentRun.steer` 行为;检查运行状态与调用 `Agent.steer()` 之间不存在异步边界,在线消息不会 fallback 到未受跟踪的 Agent 轮次。 +- 严格 steering 在与 Task 结算的竞态中失败时,`send_message` 会报告消息未送达,而且不会在该次调用中从持久化存储恢复。 +- `SubagentRun` 不提供从持久化存储恢复的操作;`SubagentControlService.sendMessage()` 将活跃消息分发至 `run.steer?()`,将非活跃消息经由底层 `SubagentService.resume()` 分发至 `SubagentProvider.resume?()`。 +- `SubagentRun.sendMessage?()` 到 `steer?()` 的重命名和后台激活路由,会在同一 PR 中同步更新 seam 模块 JSDoc、各包 README、core-data-structures 目录,以及 `tool-subagent` 中 `settleRun` 的所有权文档和测试。 +- `SubagentService` 不感知 Task 与持久化描述符;`SubagentControlService` 负责可继续激活、鉴权、按已知 child id 查找描述符,以及工具和 UI 消费方使用的 Task/run 关联。 +- 每个受支持的可继续 child 轮次都会在等待提供方之前安装 Task 关联,并保留该关联直到 run dispose 完成;按 id 路由会拒绝存活的 `ctx.agents.get(childId)`,除非关联已存在,且其 `run.localAgent` 就是该 Agent。 +- parent 恢复后,系统可以对已知的持久化 child id 鉴权,并在恢复后的 parent 作用域下,以等价的声明式组合配置按需重建该 child;恢复 fork 时只使用 child 的持久化 transcript,绝不重新 fork parent 的当前历史。 +- 描述符输入会在创建 Task 前建立快照;带版本、对模型隐藏的描述符事件位于 child 会话轮次内,不属于 surface,在压缩后仍保留,并且只有在 child header 通过直接 parent 鉴权后才会被归并。描述符省略 `subagentDepth`,恢复时的深度以持久化 header 中的值为单调下界。 +- 描述符 JSON 无效会拒绝工具调用且不创建 Task,异步 child 创建或描述符持久化失败则会 dispose 对应 run,并将已经返回的 Task 结算为 `failed`。 +- 按提供方绑定的委派工具仍位于 `@deepseek-ai/dsh-tool-subagent`;全局命名的 `send_message` 工具由 `@deepseek-ai/dsh-tool-subagent-control` 注册一次。 +- 每次激活只产生一个 Task 结果和至多一条现有 Task 主动完成通知;读取、等待或取消可能抑制该通知,steering 和 subagent 层不会添加重复通知。 +- 测试记录已停止 child 的并发准入并非原子操作:一个相同会话的发布成功,失败的 Task 进入失败状态,且其消息不会被报告为已送达。 +- 无密钥包测试覆盖 Task 所有权、dispose 顺序、用户启动和取消、运行中消息、持久化后续轮次、描述符拒绝与回滚、按已知 id 重建、作用域重建,以及所有终态下的清理。面向模型的工具及 transcript 变更具有可运行的快照覆盖。 + +## 风险 + +- 每次完成结算后的后续轮次都需要承担持久化加载和作用域 setup 成本。持久化不可用或存储的组合配置无法重建时,可继续 child 的创建会明确失败。 +- 两个调用方可能同时观察到 child 已停止,并启动相互竞争的恢复。agent 注册表会阻止相同会话的重复发布,但失败的 Task 不会送达其消息。消息也可能与取消、终态发布或 run dispose 发生竞态。第一版不承诺原子准入或恰好执行一次语义;暂缓实现的进程内 promise 预留无需公开生命周期状态机,即可消除重复的 cold resume。 +- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。控制服务会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentControlService.sendMessage()` 提交用户输入。 +- 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。 +- 用户交互要求 Task 注册表中作为 owner 的那个 parent agent 实例保持存活,因为 dispose owner 会取消并移除其 Task。用户交互还要求附加 Task 控制面。若要单独与 child 交互,后续必须将 Task 访问所有权与持久化通知目标分离。 +- 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、持久化失败,或进程在 child 首次 flush 之前退出,都可能留下 unmaterialized child id;按 id 的控制操作会报告该 id 不可用,持久化枚举也不会列出它,而不会追溯修改工具返回结果。 +- 将显式组合字段持久化到 child 日志后,其无损 JSON 与兼容性契约便成为恢复契约的一部分。后续如需支持其他组合配置输入,必须明确更改描述符版本,不能隐式持久化可通过声明合并扩展的 `AgentOptions` 字段。 +- Task 记录和活跃 run 关联位于进程内,而 child 会话具有持久性。重启会恢复会话,但不会恢复进行中的工作或其 Task 通知。 From 76ff841279a0669f7046c563bcdb352ddbcf24f9 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 22 Jul 2026 16:25:36 +0800 Subject: [PATCH 026/114] docs: keep continuable RFC self-contained --- .../2026-07-21-continuable-background-subagents.i18n.yaml | 4 ++-- .../feature/2026-07-21-continuable-background-subagents.md | 2 +- .../feature/2026-07-21-continuable-background-subagents.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml index ef603b1543..260210e81f 100644 --- a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-continuable-background-subagents.md: f0fb441cab87010f544d9be7036518b6f3e41c77 -2026-07-21-continuable-background-subagents.zh.md: 0dfb5fe8837b51c9a0220fb32f9b1340202e8ae6 +2026-07-21-continuable-background-subagents.md: 9d105743cba2045f1797a408a96b8391ccc9eb82 +2026-07-21-continuable-background-subagents.zh.md: 73428fa3595422b6743383526438f3a81a863100 diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md index f0fb441cab..9d105743cb 100644 --- a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md @@ -25,7 +25,7 @@ durable child Session Foreground delegation keeps its current one-shot behavior. The first continuable implementation covers background in-process spawn and fork children. A provider must support persisted cold resume before its children are advertised as continuable; ACP children remain one-shot until the deferred ACP continuation work below is complete. -The low-level `ctx.subagents` seam remains collection-, Task-, and persistence-agnostic. It registers providers, validates and dispatches `start` or `resume`, observes run lifecycle, and returns holder-owned runs. A separate `SubagentControlService` in `@deepseek-ai/dsh-subagent-control` owns stable continuable-child ids, descriptor persistence and lookup by known child id, Task-backed activation, and message routing. The provider-bound `@deepseek-ai/dsh-tool-subagent` plugin and human-facing adapters call that control service for continuable background work; foreground one-shot delegation still calls `ctx.subagents.start()` directly. The globally named model tool is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. Parent-to-child enumeration and `list_agents` are specified separately by the [durable subagent catalog](2026-07-22-durable-subagent-catalog-and-list-agents.md). +The low-level `ctx.subagents` seam remains collection-, Task-, and persistence-agnostic. It registers providers, validates and dispatches `start` or `resume`, observes run lifecycle, and returns holder-owned runs. A separate `SubagentControlService` in `@deepseek-ai/dsh-subagent-control` owns stable continuable-child ids, descriptor persistence and lookup by known child id, Task-backed activation, and message routing. The provider-bound `@deepseek-ai/dsh-tool-subagent` plugin and human-facing adapters call that control service for continuable background work; foreground one-shot delegation still calls `ctx.subagents.start()` directly. The globally named model tool is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. Parent-to-child enumeration and `list_agents` belong to a separate durable-catalog proposal. ### Task and cancellation ownership diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md index 0dfb5fe883..73428fa359 100644 --- a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md @@ -25,7 +25,7 @@ durable child Session 前台委派保持当前的一次性行为。第一版可继续实现覆盖进程内 spawn 和 fork child。提供方只有支持从持久化存储恢复后,才能将其 child 标记为可继续;在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。 -底层 `ctx.subagents` seam 不感知 child 集合、Task 与持久化。它注册提供方,校验并分发 `start` 或 `resume`,观察 run 生命周期,并返回由持有方负责的 run。`@deepseek-ai/dsh-subagent-control` 中单独的 `SubagentControlService` 负责管理可继续 child 的稳定 id、持久化描述符并按已知 child id 查找、由 Task 支撑的激活,以及消息路由。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器通过该控制服务处理可继续后台工作;前台一次性委派仍直接调用 `ctx.subagents.start()`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。parent 到 child 的枚举与 `list_agents` 由单独的[持久化 subagent 目录](2026-07-22-durable-subagent-catalog-and-list-agents.md)规定。 +底层 `ctx.subagents` seam 不感知 child 集合、Task 与持久化。它注册提供方,校验并分发 `start` 或 `resume`,观察 run 生命周期,并返回由持有方负责的 run。`@deepseek-ai/dsh-subagent-control` 中单独的 `SubagentControlService` 负责管理可继续 child 的稳定 id、持久化描述符并按已知 child id 查找、由 Task 支撑的激活,以及消息路由。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器通过该控制服务处理可继续后台工作;前台一次性委派仍直接调用 `ctx.subagents.start()`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。parent 到 child 的枚举与 `list_agents` 属于单独的持久化目录提案。 ### Task 与取消的所有权 From 99a778d63fe3d2df11a5e608a649021a09a6b1e9 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 17:07:38 +0800 Subject: [PATCH 027/114] feat(subagent): continuable background subagents Implement the continuable background subagents RFC: a durable child session with a series of Task-backed activations, each disposing its run before the Task settles. - dsh-subagent: rename SubagentRun.sendMessage to strict steer, drop run-level resume, add SubagentProvider.resume dispatch via SubagentService.resume, the continuation start field, and the versioned model-hidden subagent/descriptor session event. - dsh-subagent-inprocess/-spawn/-fork: publish the control-allocated child id, append the descriptor inside the initial turn, implement cold resume from the child's own transcript under the live parent scope, and strict running-only steer. - dsh-subagent-control (new): SubagentControlService owning stable child ids, descriptor snapshot/fold/authorization, Task-backed activation with settle-then-dispose ordering, the process-local active-run association, and steer-or-resume sendMessage routing. - dsh-tool-subagent: background route branches on the provider's resume capability (continuable via the control service; one-shot task for ACP), returning both child and task ids. - dsh-tool-subagent-control (new): the globally named send_message tool rendering steered/started routes. Keyless coverage spans Task ownership and disposal ordering, running delivery, cold follow-up, descriptor rejection and rollback, known-id reconstruction, kill during lookup, admission races, and a new subagent-continuable ACP snapshot scenario. --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 124 ++++ ...-21-continuable-background-subagents.zh.md | 124 ++++ ...-07-21-continuable-background-subagents.md | 148 ----- ...-21-continuable-background-subagents.zh.md | 148 ----- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 1 + docs/architecture.zh.md | 1 + docs/capability-seams.md | 6 + docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 60 +- docs/core-data-structures/subagent.md | 103 +++- docs/event-producer-consumer.md | 10 +- docs/module-graph.md | 31 +- docs/persistence-catalog.md | 17 + docs/tool-catalog.md | 35 +- examples/acp-agent/composition.md | 6 + examples/acp-agent/cordis.yml | 9 + examples/acp-agent/tests/acp.snapshot.ts | 5 + .../system-prompt.expected.md | 21 +- .../tool-schemas.expected.json | 29 +- .../both-mode-turn/tool-schemas.expected.json | 29 +- .../code-mode-turn/system-prompt.expected.md | 21 +- .../lsp-definition/tool-schemas.expected.json | 29 +- .../pty-tools/tool-schemas.expected.json | 29 +- .../tool-schemas.expected.json | 29 +- .../snapshots/subagent-continuable/input.json | 14 + .../subagent-continuable/session.1.jsonl | 17 + .../subagent-continuable/session.jsonl | 57 ++ .../stdout.expected.jsonl | 4 + .../text-turn/tool-schemas.expected.json | 29 +- .../web-fetch/tool-schemas.expected.json | 29 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/pty-tools/session.jsonl | 2 +- examples/package.json | 2 + .../cordis/tool-cordis/src/api-catalog.ts | 48 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 14 +- packages/subagent/README.zh.md | 14 +- packages/subagent/subagent-control/README.md | 37 ++ .../subagent/subagent-control/package.json | 55 ++ .../subagent/subagent-control/src/index.ts | 442 ++++++++++++++ .../subagent-control/src/invariant.ts | 32 ++ .../tests/subagent-control.spec.ts | 539 ++++++++++++++++++ .../subagent/subagent-control/tsconfig.json | 39 ++ packages/subagent/subagent-fork/README.md | 1 - packages/subagent/subagent-fork/src/index.ts | 11 +- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 15 +- .../subagent/subagent-inprocess/README.zh.md | 15 +- .../subagent/subagent-inprocess/src/index.ts | 124 +++- packages/subagent/subagent-spawn/README.md | 1 - packages/subagent/subagent-spawn/src/index.ts | 10 +- .../tests/subagent-spawn.spec.ts | 11 +- packages/subagent/subagent/README.md | 39 +- packages/subagent/subagent/src/descriptor.ts | 116 ++++ packages/subagent/subagent/src/index.ts | 68 ++- packages/subagent/subagent/src/types.ts | 86 ++- .../subagent/tool-subagent-control/README.md | 40 ++ .../tool-subagent-control/package.json | 55 ++ .../tool-subagent-control/src/index.ts | 74 +++ .../tool-subagent-control/src/invariant.ts | 30 + .../tests/tool-subagent-control.spec.ts | 153 +++++ .../tool-subagent-control/tsconfig.json | 33 ++ .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 4 +- packages/subagent/tool-subagent/README.zh.md | 4 +- packages/subagent/tool-subagent/package.json | 6 + packages/subagent/tool-subagent/src/index.ts | 173 +++--- .../tool-subagent/tests/tool-subagent.spec.ts | 121 ++-- packages/subagent/tool-subagent/tsconfig.json | 3 + pnpm-lock.yaml | 123 ++++ python/sdk-runtime/package.json | 2 + scripts/doc-budgets.manifest.json | 2 +- scripts/gen-cordis-catalog.ts | 4 + scripts/gen-doc-graphs.ts | 8 + scripts/gen-tool-catalog.ts | 21 + scripts/type-equiv.manifest.json | 10 + tsconfig.host.json | 2 + 83 files changed, 3167 insertions(+), 627 deletions(-) rename .agents/notes/{proposed => implemented}/feature/2026-07-21-continuable-background-subagents.i18n.yaml (61%) create mode 100644 .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md delete mode 100644 .agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl create mode 100644 packages/subagent/subagent-control/README.md create mode 100644 packages/subagent/subagent-control/package.json create mode 100644 packages/subagent/subagent-control/src/index.ts create mode 100644 packages/subagent/subagent-control/src/invariant.ts create mode 100644 packages/subagent/subagent-control/tests/subagent-control.spec.ts create mode 100644 packages/subagent/subagent-control/tsconfig.json create mode 100644 packages/subagent/subagent/src/descriptor.ts create mode 100644 packages/subagent/tool-subagent-control/README.md create mode 100644 packages/subagent/tool-subagent-control/package.json create mode 100644 packages/subagent/tool-subagent-control/src/index.ts create mode 100644 packages/subagent/tool-subagent-control/src/invariant.ts create mode 100644 packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts create mode 100644 packages/subagent/tool-subagent-control/tsconfig.json diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml similarity index 61% rename from .agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml rename to .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 260210e81f..d8bdceb531 100644 --- a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-continuable-background-subagents.md: 9d105743cba2045f1797a408a96b8391ccc9eb82 -2026-07-21-continuable-background-subagents.zh.md: 73428fa3595422b6743383526438f3a81a863100 +2026-07-21-continuable-background-subagents.md: 25ae582b129b2e2dc4a34c6fb3c0247aa644677a +2026-07-21-continuable-background-subagents.zh.md: f7a09ce0519874dad8b32835d0b43914a37350c8 diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md new file mode 100644 index 0000000000..25ae582b12 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -0,0 +1,124 @@ +# Agent Note: Continuable background subagents + +Status: implemented + +English | [中文](2026-07-21-continuable-background-subagents.zh.md) + +## Problem + +The subagent tool treats each delegation as one owned `SubagentRun`: foreground calls and background Tasks collect the result and then dispose the run. Disposal bounds the number of live child Agents and releases their scoped services, listeners, and provider resources. The persisted child session may survive, but the parent has no durable catalog or tool path for discovering that child and starting another turn on it. + +A Task, a run, and a child session have different lifetimes. A Task represents one background turn and has one terminal result. A `SubagentRun` owns one activation of a child. A persisted child session may contain many turns initiated by the parent or a human. Continuation must preserve per-run disposal rather than retain every historical child Agent in memory. + +## Decision + +A continuable background subagent is a durable child session with a series of Task-backed activations. The child session id, transcript, lineage, and declared composition survive in persistence. Each initial or resumed activation creates a fresh Task, `AgentHandle`, and `SubagentRun`, drives one turn, collects its result, and disposes the run before the Task becomes terminal. + +The Task's result and cancellation boundary belong to the child activation, not to whichever caller supplied its first message. Task access is authorized by the parent session id, while the Task registry retains the exact live parent Agent instance for notification and teardown. Parent and human messages therefore share one activation result while the parent remains its runtime owner: + +```text +durable child Session + activation 1: Task 1 -> SubagentRun -> AgentHandle -> dispose + activation 2: Task 2 -> SubagentRun -> AgentHandle -> dispose + activation 3: Task 3 -> SubagentRun -> AgentHandle -> dispose +``` + +Foreground delegation keeps its one-shot behavior. Continuation covers background in-process spawn and fork children. A provider supports persisted cold resume before its children are advertised as continuable — `tool-subagent` branches its background route on the mounted provider's `resume` capability — and ACP children remain one-shot until the deferred ACP continuation work below is complete. + +The low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic. It registers providers, validates and dispatches `start` or `resume`, observes run lifecycle, and returns holder-owned runs. The `SubagentControlService` (`ctx.subagentControl` in `@deepseek-ai/dsh-subagent-control`) owns stable continuable-child ids, descriptor persistence and lookup by known child id, Task-backed activation, and message routing. The provider-bound `@deepseek-ai/dsh-tool-subagent` plugin and human-facing adapters call that control service for continuable background work; foreground one-shot delegation still calls `ctx.subagents.start()` directly. The globally named model tool is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. Parent-to-child enumeration and `list_agents` belong to a separate durable-catalog proposal. + +### Task and cancellation ownership + +The initial background delegation asks the control service to start the child and register its Task. Task settlement awaits the result, calls `run.dispose()` (through the control service's `settleRun`), and only then records the `TaskOutcome`; `task_kill` aborts the active run, whose settlement path still disposes it. A terminal Task therefore leaves the durable child session but no live child Agent. + +Every later turn creates another Task. Its producer resources cover only that activation, never the child session. It reaches one terminal status, has one result, and is never reopened. The exact live parent Agent remains the Task registry owner: disposing that instance cancels, awaits, and removes its Tasks. Task APIs authorize a caller whose session id matches that owner, but a same-id replacement does not become the notification or teardown target. This preserves the `settleRun()` contract and bounds Task-owned live children by concurrent work rather than historical session count. + +Opening a child session in a human-facing adapter reads its persisted transcript and does not resume an Agent merely to display it. Human input starts or joins the same Task-backed activation used by parent input through the control service. A human-started Task retains the exact currently loaded parent Agent as its notification target, and `task_output` remains the single result path. The existing completion listener injects at most one unsolicited notice while the Task is unreported; `kill`, a terminal read, or a terminal wait may mark it reported and suppress that notice. Human interaction is therefore permitted only while that parent instance remains live. A user-owned conversation that may outlive the parent and explicitly merge a conclusion back belongs to [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md), not this Task-owned lifecycle. + +`TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations. + +Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as `killed`; the messages do not have independent results or cancellation rights. Independent cancellation requires a later message to start a separate turn instead of steering the current one. + +A cold-resume Task creates its activation-owned `AbortController` before descriptor lookup or any provider await; the lookup, direct-parent authorization, and descriptor fold run inside the Task producer, so the same signal covers them and their failures settle that Task as `failed`. A persistence call that has no signal need not stop its underlying I/O, but the control service rechecks cancellation after every such await and cannot begin or publish child work afterward. Before Agent publication, abort makes the provider reject only after its creation transaction has rolled back and reached quiescence. After publication, the provider closes the creation-signal handoff race, attaches the same signal to the live run before returning it, and cancellation stops the child turn. `task_kill` and exact-owner disposal use this path even when provider resume has not returned a `SubagentRun`; Task settlement waits for rollback or run disposal and records `killed` only after the activation is quiescent. + +### Active run association + +The control service keeps a process-local association from child session id to its current Task and, after provider publication, its run. It installs the Task association before awaiting provider start or resume, fills in the returned run, and removes the association only after run disposal and Task terminal publication. This association exists only so parent and human senders can find the same activation; it is not a durable catalog, public `ManagedSubagent`, admission reservation, or run-state machine. + +For a continuable initial activation, the control service allocates the stable child session id before Task creation and passes it in the resolved provider start request (`SubagentStartRequest.continuation`); in-process spawn and fork publish that exact id instead of allocating one internally. The background tool acknowledgement exposes both identities as `started subagent as task `. The child id names the durable conversation across activations, while the Task id names only the current activation. A failed initial Task or a process exit before the first child flush can leave an **unmaterialized child**: the caller holds a child id without a durable header and descriptor. Later by-id control operations report that id as unavailable (the started Task fails with that detail), and durable enumeration omits it. + +Every continuable child turn is admitted through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the control service synchronously compares its association with `ctx.agents.get(childId)`. A registry Agent with no association, or a registry Agent different from the associated `run.localAgent`, is an ownership conflict: the control service fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary. + +Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability by synchronously requiring `AgentStatus.running` before calling `Agent.steer()`; the check and call contain no asynchronous boundary. Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict check, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. + +The control service does not serialize two callers that race a stopped child through paths outside it, nor does it model a separate settling phase between result production and disposal. The synchronous association install before the producer's first await admits one activation per child in this process — a competing `sendMessage` during resume load observes the pending activation and fails explicitly — while a bypassing publication still loses at the Agent registry's same-session collision boundary. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction. + +### Model-facing `send_message` + +The model receives one `send_message(subagent_id, message)` tool backed by `SubagentControlService.sendMessage()`. The control operation owns steer-or-resume orchestration and is distinct from the run's `SubagentRun.steer?()`, which only delivers to an already active run. The tool performs no lifecycle routing of its own. It lives in the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package so provider-bound `@deepseek-ai/dsh-tool-subagent` instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools. + +- If the child has a running Task and live-steering capability, the service calls `run.steer(message)` and returns the existing Task id; it creates no Task of its own. +- If the child has no running Task, `send_message` creates a fresh Task, cold-resumes the durable session with the message, and returns the new Task id. +- If the active provider cannot accept live delivery, strict steering loses a race with Task settlement, or a live child exists outside the Task association, `send_message` fails rather than silently starting, resuming, or adopting an untracked turn. + +The service result identifies the route as `steered` with the existing Task id or `started` with the new Task id. Failure is explicit and says that the message was not delivered. The model-facing tool renders these distinctions so timing-dependent routing is observable to the caller. + +A delivered message has no independent result: its effect is reflected in the current Task's eventual result. A started follow-up has the fresh Task's result and existing `task_output` read path. The subagent layer adds no second completion injection. + +Human input uses the same control operation. The UI may display the child transcript and current Task state, while cancellation calls the Task service with the loaded parent as caller. Tool schema and UI adapters are consumers of one control-service contract rather than separate execution paths. + +### Durable child handle and cold resume + +The control service snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts)) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution — a one-shot `agent/pre-step` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event after the initial child `turn/start` and before its first request; it carries no `surfaceOp`, remains outside model history, and reaches persistence with that turn's flush. The append-only log retains this non-surface event when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) and its header identifies the caller as the direct parent. + +The versioned descriptor (`SUBAGENT_DESCRIPTOR_VERSION` in [descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts)) contains the subagent provider name, resolved child `agentOptions.provider` and `agentOptions.model`, and optional `persona` and `toolFilter`. It does not snapshot the merge-extensible `AgentOptions` object: unrelated extension values cannot make continuation fail merely because they are not JSON. It deliberately omits `subagentDepth`; cold resume relies on the persisted header's `delegationDepth` rather than reconstructing depth from the descriptor. `outputSchema` belongs to one activation's result contract rather than durable child composition. The child header remains authoritative for the child id, `cwd`, `parentSession`, `seedLength`, and `delegationDepth`, while the persisted child transcript owns the fork seed and subsequent history. [`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) takes the maximum of header and runtime values, so reconstructed runtime options may deepen the persisted value but never lower it and a resumed child cannot regain a top-level delegation budget. + +Cold resume cannot depend on an optional method of the old `SubagentRun`, because that run has been disposed and is not retained across process restart. `SubagentRun` has no `resume` operation: a run represents one disposable activation and exposes only activation-scoped operations. The former `SubagentRun.sendMessage?()` capability is named `SubagentRun.steer?()` so its strict live-only contract cannot be confused with service orchestration or the model-facing tool. + +`SubagentControlService`'s resume path loads the known child session, folds its descriptor, authorizes the persisted `parentSession`, and runs inside the Task it creates. It passes a fully resolved request, including the Task-owned cancellation signal, to the low-level `SubagentService.resume(provider, request)`, whose only responsibility is capability-checked provider dispatch and the ordinary run lifecycle observation used by `start`. The selected `SubagentProvider.resume?()` owns transport-specific reconstruction (in-process: `parent.ctx.agents.resume` under the currently loaded parent scope) and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag exists. `SubagentControlService.sendMessage()` chooses between the associated run's `steer?()` operation and this cold-resume path. Neither the low-level service nor a provider enumerates durable children or associates Tasks. + +The background tool validates and snapshots descriptor inputs before calling `TaskService.start()`. A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. Child creation, first-turn persistence, or descriptor persistence failure disposes any published run and settles the already-created Task as `failed`; the model observes that failure through the ordinary Task completion or `task_output` path. In-process spawn and fork reconstruct composition under the currently loaded parent scope. A fork resume loads the child's own persisted transcript, which already contains the completed-turn prefix captured at initial creation; it never forks the parent's newer history again. Resuming a parent does not eagerly resume its children. + +TODO (ACP continuation): persist the remote ACP session id as provider-specific descriptor data and implement `AcpProvider.resume?()` as spawn, initialize, `loadSession`, then prompt. The initial ACP run must verify `initialize.agentCapabilities.loadSession`, and every resumed process must use the same durable backend; replayed history from `loadSession` must not be collected as the new activation's output. Because ACP load support is negotiated per child rather than established solely by the provider method's presence, this follow-up must also define how a start result advertises child-specific continuation before ACP children enter the durable catalog. + +### Result and notification ownership + +Every continuable child activation has exactly one Task and one `TaskOutcome`, regardless of whether the parent or a human supplied the first message. The generic Task reporting contract may inject at most one unsolicited completion notice to the retained parent owner while the Task is unreported; reads, waits, and cancellation may suppress it. Running delivery joins that activation and creates neither a second Task nor a second result. The child transcript remains the human-facing detailed record; Task output remains the parent-facing final result. + +Task records and active-run associations are process-local. Persistence makes the child session resumable after restart, but does not recover an interrupted Task, its result, or its notification. Durable Task recovery is a separate concern. + +## Alternatives considered + +**Retain every background child after Task settlement.** This is the Codex-style resident-session model: follow-up delivery is cheap, but historical children retain Agent scopes, session memory, listeners, and provider resources until an explicit residency limit or eviction policy removes them. Per-activation disposal uses persistence as the continuation boundary and preserves the current resource bound. + +**Let human turns run without Tasks.** A parent message joining such a turn has no Task result or completion notice, and UI cancellation has unclear effects on the parent's contribution. Giving every activation one Task makes completion and cancellation properties of the child turn rather than its initiating caller. + +**Keep one Task for the lifetime of a child session.** A terminal Task cannot naturally become running again, and one result cannot represent multiple turns. Fresh activation-scoped Tasks preserve the generic Task contract. + +**Create a Task for every message.** Steering joins an existing turn and has no independent final result, so a Task created for steering would duplicate the active Task or report a result it does not own. Only a message that starts an activation creates a Task. + +**Split `send_message` and `follow_up`.** Separate strict operations expose an implementation-state distinction to the model without removing stopped-child races. One operation follows the Claude Code model: deliver to running work or resume a new Task-backed lifecycle. + +**Keep `resume?()` on the disposed run.** Retaining a disposed `SubagentRun` only to call `resume()` makes the old run double as a durable child handle and cannot reconstruct that object after restart. Service dispatch plus provider reconstruction makes the persistence boundary explicit. + +**Put control orchestration on `SubagentService`.** This would let one service look up descriptors, associate Tasks, and dispatch providers, but would make the collection-agnostic provider seam depend on one consumer's persistence and Task policy. A separate control service keeps start/resume transport reusable by foreground and non-Task consumers while giving tools and UI one orchestration path. + +**Add explicit activation phases.** Public `starting`/`running`/`settling` states could describe admission and cleanup precisely, but would add a lifecycle protocol the implementation does not otherwise need. The synchronous association install closes duplicate process-local cold resume without exposing those phases. + +## Testing + +- `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id; `task_kill` during a run or during cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task; cold follow-ups accumulate turns in one durable transcript with the declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once. +- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. +- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_message`, a one-shot provider keeps the plain task acknowledgement, and a resumable provider without the control service fails loud. +- The keyless ACP snapshot scenario `subagent-continuable` (examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, `task_output` collection, and a `send_message` follow-up whose started Task fails with the id unavailable. + +## Consequences + +- Every follow-up after settlement pays persistence load and scoped setup cost; in exchange, live children stay bounded by concurrent work rather than historical session count. Continuable creation fails clearly when persistence is unavailable or the stored composition cannot be reconstructed. +- Two callers may still race a stopped child through paths outside the control service. The Agent registry prevents duplicate same-session publication; a losing Task fails and its message is not delivered. A message may also race cancellation, terminal status publication, or run disposal. Admission is not claimed to be atomic or exactly-once; the synchronous process-local association install closes duplicate cold resume through the control service without a public lifecycle state machine. +- Driving a continuable child through the ordinary Agent API bypasses its Task association. The control service rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through `SubagentControlService.sendMessage()`. +- The active-run association coordinates only one runtime. Concurrent resume from multiple processes is not serialized; that deployment requires a persistence-level lease or compare-and-set operation. +- Human interaction requires the exact parent Agent instance to remain live because owner disposal cancels and removes its Tasks. It also requires an attached Task control surface. Standalone child interaction requires a future separation between Task access ownership and durable notification targeting. +- The background tool returns child and Task ids before child publication and descriptor durability. Startup failure, persistence failure, or process exit before the first child flush may leave an unmaterialized child id; by-id control reports it as unavailable and durable enumeration omits it rather than retroactively changing the tool result. +- Persisting explicit composition fields in the child log makes their lossless-JSON and compatibility contract part of resume. Later support for another composition input requires a deliberate descriptor-version change rather than implicitly persisting merge-extensible `AgentOptions` fields. +- Task records and active-run associations are process-local even though child sessions are durable. Restart recovers the session, not in-flight work or its Task notification. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md new file mode 100644 index 0000000000..f7a09ce051 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -0,0 +1,124 @@ +# Agent Note: 可继续的后台 subagent + +Status: implemented + +[English](2026-07-21-continuable-background-subagents.md) | 中文 + +## 问题 + +subagent 工具将每次委派视为一个独占的 `SubagentRun`:前台调用和后台 Task 收集结果后 dispose(资源释放)该 run。这种所有权关系能够限制存活 child agent(智能体)的数量,并释放其作用域服务、监听器及提供方资源。持久化的 child 会话可能继续存在,但 parent 缺少持久化目录和工具路径,无法发现该 child 并为其启动另一轮次。 + +Task、run 和 child 会话具有不同的生命周期。一个 Task 表示一轮后台执行,并且只有一个终态结果。一个 `SubagentRun` 拥有 child 的一次激活。一个持久化 child 会话可以包含多个由 parent 或用户发起的轮次。继续执行必须保留逐 run dispose 的约定,而不能把所有历史 child agent 都留在内存中。 + +## 决策 + +一个可继续的后台 subagent,是由一系列 Task 支撑的短期激活共同组成的持久化 child 会话。child session id、transcript(文本记录)、谱系及声明的组合配置均保留在持久化存储中。每次初始激活或恢复激活都会创建新的 Task、`AgentHandle` 和 `SubagentRun`,驱动一个轮次、收集结果,并在 Task 进入终态前 dispose 该 run。 + +Task 的结果和取消边界属于 child 激活,不属于为该激活提供第一条消息的调用方。Task 访问根据 parent session id 授权,而 Task 注册表仍保留当前存活的精确 parent Agent 实例,用于通知与资源清理。因此,只要 parent 仍是运行时 owner,parent 消息和用户消息便会共享同一个激活结果: + +```text +durable child Session + activation 1: Task 1 -> SubagentRun -> AgentHandle -> dispose + activation 2: Task 2 -> SubagentRun -> AgentHandle -> dispose + activation 3: Task 3 -> SubagentRun -> AgentHandle -> dispose +``` + +前台委派保持一次性行为。继续执行覆盖进程内 spawn 和 fork child。提供方支持从持久化存储恢复后,才能将其 child 标记为可继续——`tool-subagent` 会依据所挂载提供方的 `resume` 功能对其后台路由进行分支——在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。 + +底层 `ctx.subagents` seam 不感知 child 集合、Task 与持久化。它注册提供方,校验并分发 `start` 或 `resume`,观察 run 生命周期,并返回由持有方负责的 run。`SubagentControlService`(`@deepseek-ai/dsh-subagent-control` 中的 `ctx.subagentControl`)负责管理可继续 child 的稳定 id、持久化描述符并按已知 child id 查找、由 Task 支撑的激活,以及消息路由。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器通过该控制服务处理可继续后台工作;前台一次性委派仍直接调用 `ctx.subagents.start()`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。parent 到 child 的枚举与 `list_agents` 属于单独的持久化目录提案。 + +### Task 与取消的所有权 + +初始后台委派请求控制服务启动 child 并注册其 Task。Task 结算流程等待结果,调用 `run.dispose()`(经由控制服务的 `settleRun`),然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。 + +后续每个轮次都会创建另一个 Task。该轮 producer 持有的执行资源仅服务于这次激活,不属于 child 会话。它只会到达一次终态、只产生一个结果,也不会重新打开。Task 注册表中当前注册的那个存活 parent agent 实例仍是其 owner:dispose 该实例会取消、等待并移除其 Task。Task API 会授权 session id 与该 owner 匹配的调用方,但 id 相同的替代实例不会成为通知或资源清理目标。这一设计保留 `settleRun()` 契约,并使 Task 所拥有的存活 child 数量受并发工作量限制,而不是随历史会话数量增长。 + +用户界面适配器打开 child 会话时,只读取持久化 transcript,不会仅为展示而恢复 agent。用户输入通过控制服务,启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。因此,仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。 + +如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 + +取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 + +从持久化存储恢复的 Task 会在查找描述符或等待任何提供方操作之前,创建由本次激活持有的 `AbortController`;描述符查找、直接 parent 鉴权和描述符归并都在该 Task producer 内部执行,因此同一信号覆盖它们,其失败会将该 Task 结算为 `failed`。对于不接受信号的持久化调用,可以让底层 I/O 执行完毕;但控制服务必须在每次这类 await 返回后重新检查取消状态,如已取消,之后不得开始或发布任何 child 工作。在 Agent 发布前收到中止信号时,提供方必须先回滚其创建事务并达到完全停稳状态,然后才让恢复调用以拒绝结束。Agent 发布后,提供方必须消除创建期间移交取消信号时的竞态,在返回前将同一信号附加到存活 run;之后取消会停止 child 轮次。即使提供方的恢复调用尚未返回 `SubagentRun`,`task_kill` 与对确切 owner 实例的 dispose 仍通过这条路径生效。Task 结算会等待回滚或 run dispose 完成,只有在激活完全停稳后才记录 `killed`。 + +### 活跃 run 关联 + +控制服务在进程内维护 child session id 到当前 Task 的关联,并在提供方发布后将 run 填入该关联。它会在等待提供方 start 或 resume 之前安装 Task 关联,填入返回的 run,并且只在 run dispose 完成且 Task 终态发布后才移除该关联。该关联只用于让 parent 发送方和用户发送方找到同一次激活;它不是持久化 child 目录、公开的 `ManagedSubagent`、准入预留或 run 状态机。 + +对于可继续 child 的初始激活,控制服务会在创建 Task 前分配稳定的 child session id,并通过已完全解析的提供方启动请求(`SubagentStartRequest.continuation`)传递该 id;进程内 spawn 和 fork 会发布这一确切 id,而不是在内部另行分配。后台工具的确认消息会同时公开两种标识,格式为 `started subagent as task `。child id 在多次激活中始终指代同一个持久化对话,Task id 则只指代当前激活。初始 Task 失败,或进程在 child 首次 flush 之前退出,都可能留下一个 **unmaterialized child**:调用方持有 child id,但不存在持久化 header 和描述符。后续按 id 的控制操作会报告该 id 不可用(已启动的 Task 会带着该详情失败),持久化枚举也不会列出它。 + +每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,控制服务会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:控制服务会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。 + +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 通过以下方式实现该功能:调用 `Agent.steer()` 前同步要求 `AgentStatus.running`,检查与调用之间不存在异步边界。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 + +控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 + +### 面向模型的 `send_message` + +模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 + +- 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message)` 并返回现有 task id;它不会创建新 Task。 +- 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 +- 如果活跃提供方无法接收在线消息、严格 steering 在与 Task 结算的竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。 + +服务结果将路由标识为 `steered` 并携带现有 task id,或标识为 `started` 并携带新的 task id。失败结果会明确说明消息未送达。面向模型的工具会呈现这些差异,让调用方能够观察由时序决定的实际路由。 + +发送到现有 run 的消息没有独立结果,其效果体现在当前 Task 的最终结果中。启动的后续轮次具有新 Task 的结果,并使用现有 `task_output` 读取路径。subagent 层不会再注入第二份完成通知。 + +用户输入使用同一个控制操作。UI 可以展示 child transcript 和当前 Task 状态,取消操作则以已加载 parent 作为调用方访问 Task 服务。工具 schema 与 UI 适配器消费同一个控制服务契约,不建立彼此独立的执行路径。 + +### 持久化 child handle 与从持久化存储恢复 + +控制服务在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动安装的一次性 `agent/pre-step` 监听器——会在 child 初始 `turn/start` 之后、首次请求之前追加一个对模型隐藏的 `subagent/descriptor` 事件。该事件不携带 `surfaceOp`,不进入模型历史,并随该轮次的 flush 一并进入持久化存储。当压缩替换 surface 历史时,仅追加日志仍保留这个不属于 surface 的事件。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 + +版本化描述符([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)包含 subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果契约,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`seedLength` 和 `delegationDepth` 的权威信息,持久化 child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。 + +从持久化存储恢复不能依赖旧 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。`SubagentRun` 不含 `resume` 操作:run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。原有的 `SubagentRun.sendMessage?()` 功能改名为 `SubagentRun.steer?()`,以免其严格的仅在线契约与服务编排或面向模型的工具混淆。 + +`SubagentControlService` 的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。 + +后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。child 创建、首轮持久化或描述符持久化失败时,系统会 dispose 所有已发布的 run,并将已经创建的 Task 结算为 `failed`;模型通过普通 Task 完成通知或 `task_output` 路径观察该失败。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 + +TODO(ACP 继续执行):将远端 ACP session id 作为提供方专用描述符数据持久化,并实现 `AcpProvider.resume?()`,依次执行 spawn、initialize、`loadSession` 和 prompt。初始 ACP run 必须检查 `initialize.agentCapabilities.loadSession`,恢复后的每个进程必须使用同一个持久化后端;`loadSession` 回放的历史消息不得计入新激活的输出。由于 ACP 的加载支持是按 child 协商的,不能仅根据提供方是否存在该方法来确定,因此该后续工作还必须定义 start 结果如何声明单个 child 支持继续执行,之后才能将 ACP child 写入持久化目录。 + +### 结果与通知所有权 + +每次可继续 child 激活都恰好拥有一个 Task 和一个 `TaskOutcome`,无论第一条消息由 parent 还是用户提供。只要 Task 尚未标记为已报告,通用 Task 报告契约最多会向保留的 parent owner 注入一条主动完成通知;读取、等待和取消都可能抑制该通知。发送到运行中激活的消息会加入该激活,不会创建第二个 Task 或第二份结果。child transcript 是面向用户的详细记录;Task 输出是面向 parent 的最终结果。 + +Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可在重启后恢复,但不会恢复中断的 Task、其结果或通知。持久化 Task 恢复属于另一个问题。 + +## 已考虑的替代方案 + +**在 Task 结算后保留所有后台 child。** 这是 Codex 风格的常驻会话模型:发送后续消息成本较低,但历史 child 会持续占用 agent 作用域、会话内存、监听器和提供方资源,直至显式常驻数量上限或淘汰策略将其移除。逐激活 dispose 使用持久化作为继续执行边界,同时保留当前的资源上限。 + +**允许用户轮次不使用 Task。** parent 消息加入此类轮次后,没有对应的 Task 结果或完成通知;UI 取消对 parent 所发消息的影响也不明确。让每次激活都拥有一个 Task,可使完成与取消成为 child 轮次的属性,而不是初始调用方的属性。 + +**在 child 会话整个生命周期内复用一个 Task。** 终态 Task 无法自然地再次进入运行状态,一个结果也无法表示多个轮次。每次激活创建新 Task 可以保留通用 Task 契约。 + +**为每条消息创建 Task。** 发送到现有 run 的消息会加入已有轮次,不产生独立的最终结果;为这类消息创建 Task,会重复当前 Task,或报告一个它并不拥有的结果。只有启动新激活的消息才会创建 Task。 + +**拆分 `send_message` 与 `follow_up`。** 两个严格操作会向模型暴露实现状态差异,却无法消除 child 已停止时的竞态。单一操作采用 Claude Code 模型:向运行中的工作发送消息,或恢复一个由新 Task 支撑的生命周期。 + +**在已 dispose 的 run 上保留 `resume?()`。** 如果仅为调用 `resume()` 而保留已 dispose 的 `SubagentRun`,旧 run 会同时充当持久化 child handle,而且进程重启后无法重建该对象。由服务分发、提供方重建,可明确表达持久化边界。 + +**将控制编排放在 `SubagentService` 上。** 这样一个服务就能查找描述符、关联 Task 并分发提供方,但会迫使不感知集合的提供方 seam 依赖某个消费方的持久化与 Task 策略。单独的控制服务让前台及不使用 Task 的消费方可以复用 start/resume 传输,同时为工具和 UI 提供统一的编排路径。 + +**增加显式激活阶段。** 公开的 `starting`/`running`/`settling` 状态可以准确描述准入和清理,但会引入实现本身并不需要的生命周期协议。同步安装关联无需暴露这些阶段,即可消除进程内重复的 cold resume。 + +## 测试 + +- `packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,而不创建第二个 Task;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 +- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 +- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 +- 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、`task_output` 结果收集,以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 + +## 影响 + +- 每次完成结算后的后续轮次都需要承担持久化加载和作用域 setup 成本;作为交换,存活 child 的数量受并发工作量限制,而不是随历史会话数量增长。持久化不可用或存储的组合配置无法重建时,可继续 child 的创建会明确失败。 +- 两个调用方仍可能通过控制服务外部的路径争抢已停止的 child。Agent 注册表会阻止相同会话的重复发布;失败的 Task 会失败,且其消息不会送达。消息也可能与取消、终态状态发布或 run dispose 发生竞态。准入不承诺原子或恰好执行一次;在进程内同步安装的关联无需公开生命周期状态机,即可通过控制服务消除重复的 cold resume。 +- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。控制服务会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentControlService.sendMessage()` 提交用户输入。 +- 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。 +- 用户交互要求作为 owner 的那个精确 parent Agent 实例保持存活,因为 dispose owner 会取消并移除其 Task。用户交互还要求附加 Task 控制面。若要单独与 child 交互,后续必须将 Task 访问所有权与持久化通知目标分离。 +- 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、持久化失败,或进程在 child 首次 flush 之前退出,都可能留下 unmaterialized child id;按 id 的控制操作会报告该 id 不可用,持久化枚举也不会列出它,而不会追溯修改工具返回结果。 +- 将显式组合字段持久化到 child 日志后,其无损 JSON 与兼容性契约便成为恢复契约的一部分。后续如需支持其他组合配置输入,必须明确更改描述符版本,不能隐式持久化可通过声明合并扩展的 `AgentOptions` 字段。 +- Task 记录和活跃 run 关联位于进程内,而 child 会话具有持久性。重启会恢复会话,但不会恢复进行中的工作或其 Task 通知。 diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md deleted file mode 100644 index 9d105743cb..0000000000 --- a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md +++ /dev/null @@ -1,148 +0,0 @@ -# Agent Note: Continuable background subagents - -Status: proposed - -English | [中文](2026-07-21-continuable-background-subagents.zh.md) - -## Problem - -The subagent tool treats each delegation as one owned `SubagentRun`: foreground calls and background Tasks collect the result and then dispose the run. Disposal bounds the number of live child Agents and releases their scoped services, listeners, and provider resources. The persisted child session may survive, but the parent has no durable catalog or tool path for discovering that child and starting another turn on it. - -A Task, a run, and a child session have different lifetimes. A Task represents one background turn and has one terminal result. A `SubagentRun` owns one activation of a child. A persisted child session may contain many turns initiated by the parent or a human. Continuation must preserve per-run disposal rather than retain every historical child Agent in memory. - -## Proposal - -A continuable background subagent is a durable child session with a series of Task-backed activations. The child session id, transcript, lineage, and declared composition survive in persistence. Each initial or resumed activation creates a fresh Task, `AgentHandle`, and `SubagentRun`, drives one turn, collects its result, and disposes the run before the Task becomes terminal. - -The Task's result and cancellation boundary belong to the child activation, not to whichever caller supplied its first message. Task access is authorized by the parent session id, while the Task registry retains the exact live parent Agent instance for notification and teardown. Parent and human messages therefore share one activation result while the parent remains its runtime owner: - -```text -durable child Session - activation 1: Task 1 -> SubagentRun -> AgentHandle -> dispose - activation 2: Task 2 -> SubagentRun -> AgentHandle -> dispose - activation 3: Task 3 -> SubagentRun -> AgentHandle -> dispose -``` - -Foreground delegation keeps its current one-shot behavior. The first continuable implementation covers background in-process spawn and fork children. A provider must support persisted cold resume before its children are advertised as continuable; ACP children remain one-shot until the deferred ACP continuation work below is complete. - -The low-level `ctx.subagents` seam remains collection-, Task-, and persistence-agnostic. It registers providers, validates and dispatches `start` or `resume`, observes run lifecycle, and returns holder-owned runs. A separate `SubagentControlService` in `@deepseek-ai/dsh-subagent-control` owns stable continuable-child ids, descriptor persistence and lookup by known child id, Task-backed activation, and message routing. The provider-bound `@deepseek-ai/dsh-tool-subagent` plugin and human-facing adapters call that control service for continuable background work; foreground one-shot delegation still calls `ctx.subagents.start()` directly. The globally named model tool is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. Parent-to-child enumeration and `list_agents` belong to a separate durable-catalog proposal. - -### Task and cancellation ownership - -The initial background delegation asks the control service to start the child and register its Task. Task settlement awaits the result, calls `run.dispose()`, and only then records the `TaskOutcome`; `task_kill` aborts the active run, whose settlement path still disposes it. A terminal Task therefore leaves the durable child session but no live child Agent. - -Every later turn creates another Task. Its producer resources cover only that activation, never the child session. It reaches one terminal status, has one result, and is never reopened. The exact live parent Agent remains the Task registry owner: disposing that instance cancels, awaits, and removes its Tasks. Task APIs authorize a caller whose session id matches that owner, but a same-id replacement does not become the notification or teardown target. This preserves the existing `settleRun()` contract and bounds Task-owned live children by concurrent work rather than historical session count. - -Opening a child session in a human-facing adapter reads its persisted transcript and does not resume an Agent merely to display it. Human input starts or joins the same Task-backed activation used by parent input through the control service. A human-started Task retains the exact currently loaded parent Agent as its notification target, and `task_output` remains the single result path. The existing completion listener injects at most one unsolicited notice while the Task is unreported; `kill`, a terminal read, or a terminal wait may mark it reported and suppress that notice. The first version therefore permits human interaction only while that parent instance remains live. A user-owned conversation that may outlive the parent and explicitly merge a conclusion back belongs to [interactive side sessions](2026-07-08-interactive-side-sessions.md), not this Task-owned lifecycle. - -`TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations. - -Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as `killed`; the messages do not have independent results or cancellation rights. Independent cancellation requires a later message to start a separate turn instead of steering the current one. - -A cold-resume Task creates its activation-owned `AbortController` before descriptor lookup or any provider await and passes that signal through `SubagentControlService.resume()`, `SubagentService.resume()`, and `SubagentProvider.resume?()`. A persistence call that has no signal need not stop its underlying I/O, but the control service rechecks cancellation after every such await and cannot begin or publish child work afterward. Before Agent publication, abort makes the provider reject only after its creation transaction has rolled back and reached quiescence. After publication, the provider closes the creation-signal handoff race, attaches the same signal to the live run before returning it, and cancellation stops the child turn. `task_kill` and exact-owner disposal use this path even when provider resume has not returned a `SubagentRun`; Task settlement waits for rollback or run disposal and records `killed` only after the activation is quiescent. - -### Active run association - -The control service keeps a process-local association from child session id to its current Task and, after provider publication, its run. It installs the Task association before awaiting provider start or resume, fills in the returned run, and removes the association only after run disposal and Task terminal publication. This association exists only so parent and human senders can find the same activation; it is not a durable catalog, public `ManagedSubagent`, admission reservation, or run-state machine. - -For a continuable initial activation, the control service allocates the stable child session id before Task creation and passes it in the resolved provider start request; in-process spawn and fork publish that exact id instead of allocating one internally. The background tool acknowledgement exposes both identities as `started subagent as task `. The child id names the durable conversation across activations, while the Task id names only the current activation. A failed initial Task or a process exit before the first child flush can leave an **unmaterialized child**: the caller holds a child id without a durable header and descriptor. Later by-id control operations report that id as unavailable, and durable enumeration omits it. - -The first version admits every continuable child turn through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the control service synchronously compares its association with `ctx.agents.get(childId)`. A registry Agent with no association, or a registry Agent different from the associated `run.localAgent`, is an ownership conflict: the control service fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary. - -Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability by synchronously requiring `AgentStatus.running` before calling `Agent.steer()`; the check and call contain no asynchronous boundary. Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict check, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. - -The first version does not serialize two callers that concurrently observe a stopped child, nor does it model a separate settling phase between result production and disposal. Concurrent cold-resume attempts may both create Tasks, but the Agent registry permits only one same-session Agent to publish; a losing Task fails and its message is not delivered. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction. - -Atomic process-local admission is on hold. The smallest follow-up would synchronously reserve the child before awaiting resume, conceptually with `Map>`; later callers would await the same publication promise and then use strict live delivery. This would close duplicate cold resume without adding a public `ManagedSubagent` or explicit `starting`/`running`/`settling` protocol. - -### Model-facing `send_message` - -The model receives one `send_message(subagent_id, message)` tool backed by `SubagentControlService.sendMessage()`. The control operation owns steer-or-resume orchestration and is distinct from the run's `SubagentRun.steer?()`, which only delivers to an already active run. The tool performs no lifecycle routing of its own. It lives in a separately loaded `@deepseek-ai/dsh-tool-subagent-control` package so provider-bound `@deepseek-ai/dsh-tool-subagent` instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools. - -- If the child has a running Task and live-steering capability, the service calls `run.steer(message)` and returns the existing Task id; it creates no Task of its own. -- If the child has no running Task, `send_message` creates a fresh Task, cold-resumes the durable session with the message, and returns the new Task id. -- If the active provider cannot accept live delivery, strict steering loses a race with Task settlement, or a live child exists outside the Task association, `send_message` fails rather than silently starting, resuming, or adopting an untracked turn. - -The service result identifies the route as `steered` with the existing Task id or `started` with the new Task id. Failure is explicit and says that the message was not delivered. The model-facing tool renders these distinctions so timing-dependent routing is observable to the caller. - -A delivered message has no independent result: its effect is reflected in the current Task's eventual result. A started follow-up has the fresh Task's result and existing `task_output` read path. The subagent layer adds no second completion injection. - -Human input uses the same control operation. The UI may display the child transcript and current Task state, while cancellation calls the Task service with the loaded parent as caller. Tool schema and UI adapters are consumers of one control-service contract rather than separate execution paths. - -### Durable child handle and cold resume - -The control service snapshots every descriptor input with [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution appends one model-hidden `subagent/descriptor` event after the initial child `turn/start` and before its first request; it carries no `surfaceOp`, remains outside model history, and reaches persistence with that turn's flush. The append-only log retains this non-surface event when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor and its header identifies the caller as the direct parent. - -The versioned descriptor contains the subagent provider name, resolved child `agentOptions.provider` and `agentOptions.model`, and optional `persona` and `toolFilter`. It does not snapshot the merge-extensible `AgentOptions` object: unrelated extension values cannot make continuation fail merely because they are not JSON. It deliberately omits `subagentDepth`; cold resume relies on the persisted header's `delegationDepth` rather than reconstructing depth from the descriptor. `outputSchema` belongs to one activation's result contract rather than durable child composition. The child header remains authoritative for the child id, `cwd`, `parentSession`, `seedLength`, and `delegationDepth`, while the persisted child transcript owns the fork seed and subsequent history. [`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) takes the maximum of header and runtime values, so reconstructed runtime options may deepen the persisted value but never lower it and a resumed child cannot regain a top-level delegation budget. - -Cold resume cannot depend on an optional method of the old `SubagentRun`, because that run has been disposed and is not retained across process restart. This proposal removes `SubagentRun.resume?()`: a run represents one disposable activation and exposes only activation-scoped operations. It also renames the existing `SubagentRun.sendMessage?()` capability to `SubagentRun.steer?()` so its strict live-only contract cannot be confused with service orchestration or the model-facing tool. - -`SubagentControlService.resume()` loads the known child session, folds its descriptor, authorizes the persisted `parentSession`, and creates the Task. It passes a fully resolved request, including the Task-owned cancellation signal, to the low-level `SubagentService.resume(provider, request)`, whose only responsibility is capability-checked provider dispatch and the ordinary run lifecycle observation used by `start`. The selected `SubagentProvider.resume?()` owns transport-specific reconstruction and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag is added. `SubagentControlService.sendMessage()` chooses between the associated run's `steer?()` operation and this cold-resume path. Neither the low-level service nor a provider enumerates durable children or associates Tasks. - -The background tool validates and snapshots descriptor inputs before calling `TaskService.start()`. A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. Child creation, first-turn persistence, or descriptor persistence failure disposes any published run and settles the already-created Task as `failed`; the model observes that failure through the ordinary Task completion or `task_output` path. The first implementation reconstructs in-process spawn and fork composition under the currently loaded parent scope. A fork resume loads the child's own persisted transcript, which already contains the completed-turn prefix captured at initial creation; it never forks the parent's newer history again. Resuming a parent does not eagerly resume its children. - -TODO (ACP continuation): persist the remote ACP session id as provider-specific descriptor data and implement `AcpProvider.resume?()` as spawn, initialize, `loadSession`, then prompt. The initial ACP run must verify `initialize.agentCapabilities.loadSession`, and every resumed process must use the same durable backend; replayed history from `loadSession` must not be collected as the new activation's output. Because ACP load support is negotiated per child rather than established solely by the provider method's presence, this follow-up must also define how a start result advertises child-specific continuation before ACP children enter the durable catalog. - -### Result and notification ownership - -Every continuable child activation has exactly one Task and one `TaskOutcome`, regardless of whether the parent or a human supplied the first message. The generic Task reporting contract may inject at most one unsolicited completion notice to the retained parent owner while the Task is unreported; reads, waits, and cancellation may suppress it. Running delivery joins that activation and creates neither a second Task nor a second result. The child transcript remains the human-facing detailed record; Task output remains the parent-facing final result. - -Task records and active-run associations are process-local. Persistence makes the child session resumable after restart, but does not recover an interrupted Task, its result, or its notification. Durable Task recovery is a separate concern. - -### Implementation boundary - -One implementation PR delivers this proposal: stable child-id allocation and provider handoff, the child-session descriptor event, `SubagentControlService`, in-process provider cold resume, existing background-delegation routing, strict spawn/fork steering, active-run association, human message routing, and the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package with its `send_message` tool. Parent-to-child enumeration and `list_agents` consume this durable child-handle contract but remain a separate feature and PR. ACP continuation is a separate provider follow-up after the child-specific advertisement contract above is resolved. - -## Alternatives considered - -**Retain every background child after Task settlement.** This is the Codex-style resident-session model: follow-up delivery is cheap, but historical children retain Agent scopes, session memory, listeners, and provider resources until an explicit residency limit or eviction policy removes them. Per-activation disposal uses persistence as the continuation boundary and preserves the current resource bound. - -**Let human turns run without Tasks.** A parent message joining such a turn has no Task result or completion notice, and UI cancellation has unclear effects on the parent's contribution. Giving every activation one Task makes completion and cancellation properties of the child turn rather than its initiating caller. - -**Keep one Task for the lifetime of a child session.** A terminal Task cannot naturally become running again, and one result cannot represent multiple turns. Fresh activation-scoped Tasks preserve the generic Task contract. - -**Create a Task for every message.** Steering joins an existing turn and has no independent final result, so a Task created for steering would duplicate the active Task or report a result it does not own. Only a message that starts an activation creates a Task. - -**Split `send_message` and `follow_up`.** Separate strict operations expose an implementation-state distinction to the model without removing stopped-child races. One operation follows the Claude Code model: deliver to running work or resume a new Task-backed lifecycle. - -**Keep `resume?()` on the disposed run.** Retaining a disposed `SubagentRun` only to call `resume()` makes the old run double as a durable child handle and cannot reconstruct that object after restart. Service dispatch plus provider reconstruction makes the persistence boundary explicit. - -**Put control orchestration on `SubagentService`.** This would let one service look up descriptors, associate Tasks, and dispatch providers, but would make the collection-agnostic provider seam depend on one consumer's persistence and Task policy. A separate control service keeps start/resume transport reusable by foreground and non-Task consumers while giving tools and UI one orchestration path. - -**Add explicit activation phases.** Public `starting`/`running`/`settling` states could describe admission and cleanup precisely, but would add a lifecycle protocol that the first implementation does not otherwise need. The on-hold promise reservation closes duplicate process-local cold resume without exposing those phases. - -## Acceptance criteria - -- Initial and resumed continuable activations create a fresh Task and dispose their run before that Task becomes terminal. -- Opening a persisted child for display creates no Agent activation; human input under a loaded parent starts or joins a Task-backed activation. -- Human and parent messages delivered to one running activation share its Task result and cancellation outcome. -- Cancelling a human-started activation aborts and disposes its run and settles the Task as `killed`; its completion notice follows the generic at-most-one reporting contract and may be suppressed when the Task is already reported. -- A cold-resume Task owns its AbortSignal before descriptor lookup; cancellation during lookup or provider resume prevents later publication or cancels the published run, and Task settlement waits for rollback or disposal quiescence before reporting `killed`. -- A human-facing adapter attaches a Task control surface before accepting child input; absence of a surface fails clearly instead of starting untracked work. -- `send_message` delivers to a running child without creating a Task and cold-resumes a stopped child into a fresh Task-backed activation. -- `send_message` reports whether it `steered` an existing Task or `started` a new Task, including the relevant Task id, and reports a failure as not delivered. -- Initial continuable delegation allocates its child id before Task creation, passes that id through provider publication, and returns both the stable child id and activation Task id to the model. -- Spawn and fork implement strict `SubagentRun.steer` behavior with no asynchronous boundary between the running check and `Agent.steer()`; live delivery cannot fall back to an untracked Agent turn. -- If strict steering loses a race with Task settlement, `send_message` reports the message as not delivered and does not cold-resume within that call. -- `SubagentRun` has no cold-resume operation; `SubagentControlService.sendMessage()` dispatches active delivery to `run.steer?()` and inactive delivery through low-level `SubagentService.resume()` to `SubagentProvider.resume?()`. -- The `SubagentRun.sendMessage?()` to `steer?()` rename and the background activation route update the seam module JSDoc, package READMEs, core-data-structures catalog, and `tool-subagent` `settleRun` ownership documentation and tests in the same PR. -- `SubagentService` remains unaware of Tasks and durable descriptors; `SubagentControlService` owns continuable activation, authorization, descriptor lookup by known child id, and Task/run association for tool and UI consumers. -- Every supported continuable child turn installs its Task association before provider awaits and retains it through run disposal; by-id routing rejects a live `ctx.agents.get(childId)` unless the association exists and its `run.localAgent` is that exact Agent. -- A known persisted child id can be authorized and lazily reconstructed after parent resume with equivalent declared composition under the resumed parent's scope; fork resume uses only the child's persisted transcript and never re-forks current parent history. -- Descriptor inputs are snapshotted before Task creation; a versioned model-hidden descriptor event is turn-enclosed in the child session, excluded from the surface, retained across compaction, and folded only after the child header passes direct-parent authorization. The descriptor omits `subagentDepth`, and resumed depth uses the persisted header as its monotone floor. -- Invalid descriptor JSON rejects the tool without creating a Task, while asynchronous child or descriptor persistence failure disposes the run and settles the returned Task as `failed`. -- Provider-bound delegation tools remain in `@deepseek-ai/dsh-tool-subagent`; the globally named `send_message` tool registers once from `@deepseek-ai/dsh-tool-subagent-control`. -- Each activation produces one Task result and at most one unsolicited existing Task completion notice; reads, waits, or cancellation may suppress that notice, and steering and the subagent layer add no duplicate notification. -- Tests document that concurrent stopped-child admission is not atomic: one same-session publication wins, a losing Task fails, and the losing message is not reported as delivered. -- Keyless package tests cover Task ownership, disposal ordering, human start and cancellation, running delivery, cold follow-up, descriptor rejection and rollback, known-id reconstruction, scope reconstruction, and terminal cleanup. Model-visible tool and transcript changes have runnable snapshot coverage. - -## Risks - -- Every follow-up after settlement pays persistence load and scoped setup cost. Continuable creation fails clearly when persistence is unavailable or the stored composition cannot be reconstructed. -- Two callers may concurrently observe a stopped child and start competing resumes. The Agent registry prevents duplicate same-session publication, but a losing Task fails and its message is not delivered. A message may also race cancellation, terminal status publication, or run disposal. The first version does not claim atomic or exactly-once admission; the on-hold process-local promise reservation can close duplicate cold resume without requiring a public lifecycle state machine. -- Driving a continuable child through the ordinary Agent API bypasses its Task association. The control service rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through `SubagentControlService.sendMessage()`. -- The active-run association coordinates only one runtime. Concurrent resume from multiple processes is not serialized; that deployment requires a persistence-level lease or compare-and-set operation. -- Human interaction requires the exact parent Agent instance to remain live because owner disposal cancels and removes its Tasks. It also requires an attached Task control surface. Standalone child interaction requires a future separation between Task access ownership and durable notification targeting. -- The background tool returns child and Task ids before child publication and descriptor durability. Startup failure, persistence failure, or process exit before the first child flush may leave an unmaterialized child id; by-id control reports it as unavailable and durable enumeration omits it rather than retroactively changing the tool result. -- Persisting explicit composition fields in the child log makes their lossless-JSON and compatibility contract part of resume. Later support for another composition input requires a deliberate descriptor-version change rather than implicitly persisting merge-extensible `AgentOptions` fields. -- Task records and active-run associations are process-local even though child sessions are durable. Restart recovers the session, not in-flight work or its Task notification. diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md deleted file mode 100644 index 73428fa359..0000000000 --- a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md +++ /dev/null @@ -1,148 +0,0 @@ -# Agent Note: 可继续的后台 subagent - -Status: proposed - -[English](2026-07-21-continuable-background-subagents.md) | 中文 - -## 问题 - -subagent 工具将每次委派视为一个独占的 `SubagentRun`:前台调用和后台 Task 收集结果后 dispose(资源释放)该 run。这种所有权关系能够限制存活 child agent(智能体)的数量,并释放其作用域服务、监听器及提供方资源。持久化的 child 会话可能继续存在,但 parent 缺少持久化目录和工具路径,无法发现该 child 并为其启动另一轮次。 - -Task、run 和 child 会话具有不同的生命周期。一个 Task 表示一轮后台执行,并且只有一个终态结果。一个 `SubagentRun` 拥有 child 的一次激活。一个持久化 child 会话可以包含多个由 parent 或用户发起的轮次。继续执行必须保留逐 run dispose 的约定,而不能把所有历史 child agent 都留在内存中。 - -## 提案 - -一个可继续的后台 subagent,是由一系列 Task 支撑的短期激活共同组成的持久化 child 会话。child session id、transcript(文本记录)、谱系及声明的组合配置均保留在持久化存储中。每次初始激活或恢复激活都会创建新的 Task、`AgentHandle` 和 `SubagentRun`,驱动一个轮次、收集结果,并在 Task 进入终态前 dispose 该 run。 - -Task 的结果和取消边界属于 child 激活,不属于为该激活提供第一条消息的调用方。Task 访问根据 parent session id 授权,而 Task 注册表仍保留当前存活的精确 parent Agent 实例,用于通知与资源清理。因此,只要 parent 仍是运行时 owner,parent 消息和用户消息便会共享同一个激活结果: - -```text -durable child Session - activation 1: Task 1 -> SubagentRun -> AgentHandle -> dispose - activation 2: Task 2 -> SubagentRun -> AgentHandle -> dispose - activation 3: Task 3 -> SubagentRun -> AgentHandle -> dispose -``` - -前台委派保持当前的一次性行为。第一版可继续实现覆盖进程内 spawn 和 fork child。提供方只有支持从持久化存储恢复后,才能将其 child 标记为可继续;在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。 - -底层 `ctx.subagents` seam 不感知 child 集合、Task 与持久化。它注册提供方,校验并分发 `start` 或 `resume`,观察 run 生命周期,并返回由持有方负责的 run。`@deepseek-ai/dsh-subagent-control` 中单独的 `SubagentControlService` 负责管理可继续 child 的稳定 id、持久化描述符并按已知 child id 查找、由 Task 支撑的激活,以及消息路由。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器通过该控制服务处理可继续后台工作;前台一次性委派仍直接调用 `ctx.subagents.start()`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。parent 到 child 的枚举与 `list_agents` 属于单独的持久化目录提案。 - -### Task 与取消的所有权 - -初始后台委派请求控制服务启动 child 并注册其 Task。Task 结算流程等待结果,调用 `run.dispose()`,然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。 - -后续每个轮次都会创建另一个 Task。该轮 producer 持有的执行资源仅服务于这次激活,不属于 child 会话。它只会到达一次终态、只产生一个结果,也不会重新打开。Task 注册表中当前注册的那个存活 parent agent 实例仍是其 owner:dispose 该实例会取消、等待并移除其 Task。Task API 会授权 session id 与该 owner 匹配的调用方,但 id 相同的替代实例不会成为通知或资源清理目标。这一设计保留现有 `settleRun()` 契约,并使 Task 所拥有的存活 child 数量受并发工作量限制,而不是随历史会话数量增长。 - -用户界面适配器打开 child 会话时,只读取持久化 transcript,不会仅为展示而恢复 agent。用户输入通过控制服务,启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。第一版仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。 - -如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 - -取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 - -从持久化存储恢复的 Task 会在查找描述符或等待任何提供方操作之前,创建由本次激活持有的 `AbortController`,并通过 `SubagentControlService.resume()`、`SubagentService.resume()` 和 `SubagentProvider.resume?()` 逐层传递其信号。对于不接受信号的持久化调用,可以让底层 I/O 执行完毕;但控制服务必须在每次这类 await 返回后重新检查取消状态,如已取消,之后不得开始或发布任何 child 工作。在 Agent 发布前收到中止信号时,提供方必须先回滚其创建事务并达到完全停稳状态,然后才让恢复调用以拒绝结束。Agent 发布后,提供方必须消除创建期间移交取消信号时的竞态,在返回前将同一信号附加到存活 run;之后取消会停止 child 轮次。即使提供方的恢复调用尚未返回 `SubagentRun`,`task_kill` 与对确切 owner 实例的 dispose 仍通过这条路径生效。Task 结算会等待回滚或 run dispose 完成,只有在激活完全停稳后才记录 `killed`。 - -### 活跃 run 关联 - -控制服务在进程内维护 child session id 到当前 Task 的关联,并在提供方发布后将 run 填入该关联。它会在等待提供方 start 或 resume 之前安装 Task 关联,填入返回的 run,并且只在 run dispose 完成且 Task 终态发布后才移除该关联。该关联只用于让 parent 发送方和用户发送方找到同一次激活;它不是持久化 child 目录、公开的 `ManagedSubagent`、准入预留或 run 状态机。 - -对于可继续 child 的初始激活,控制服务会在创建 Task 前分配稳定的 child session id,并通过已完全解析的提供方启动请求传递该 id;进程内 spawn 和 fork 会发布这一确切 id,而不是在内部另行分配。后台工具的确认消息会同时公开两种标识,格式为 `started subagent as task `。child id 在多次激活中始终指代同一个持久化对话,Task id 则只指代当前激活。初始 Task 失败,或进程在 child 首次 flush 之前退出,都可能留下一个 **unmaterialized child**:调用方持有 child id,但不存在持久化 header 和描述符。后续按 id 的控制操作会报告该 id 不可用,持久化枚举也不会列出它。 - -第一版要求每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,控制服务会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:控制服务会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。 - -系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 通过以下方式实现该功能:调用 `Agent.steer()` 前同步要求 `AgentStatus.running`,检查与调用之间不存在异步边界。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 - -第一版不会串行化两个同时观察到 child 已停止的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。并发的 cold resume 尝试可能都会创建 Task,但 agent 注册表只允许一个相同会话的 agent 完成发布;失败的 Task 不会送达其消息。发送也可能因与启动、取消、完成或清理发生竞态而失败。本提案明确接受这些限制,不为此引入更大的生命周期抽象。 - -原子的进程内准入暂缓实现。最小的后续方案是在等待 resume 之前同步预留 child,概念上使用 `Map>`;后续调用方等待同一个发布 promise,再使用严格的在线消息功能。这样无需添加公开的 `ManagedSubagent` 或显式 `starting`/`running`/`settling` 协议,即可消除重复的 cold resume。 - -### 面向模型的 `send_message` - -模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 - -- 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message)` 并返回现有 task id;它不会创建新 Task。 -- 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 -- 如果活跃提供方无法接收在线消息、严格 steering 在与 Task 结算的竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。 - -服务结果将路由标识为 `steered` 并携带现有 task id,或标识为 `started` 并携带新的 task id。失败结果会明确说明消息未送达。面向模型的工具会呈现这些差异,让调用方能够观察由时序决定的实际路由。 - -发送到现有 run 的消息没有独立结果,其效果体现在当前 Task 的最终结果中。启动的后续轮次具有新 Task 的结果,并使用现有 `task_output` 读取路径。subagent 层不会再注入第二份完成通知。 - -用户输入使用同一个控制操作。UI 可以展示 child transcript 和当前 Task 状态,取消操作则以已加载 parent 作为调用方访问 Task 服务。工具 schema 与 UI 适配器消费同一个控制服务契约,不建立彼此独立的执行路径。 - -### 持久化 child handle 与从持久化存储恢复 - -控制服务在创建 Task 前,通过 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution 会在 child 初始 `turn/start` 之后、首次请求之前追加一个对模型隐藏的 `subagent/descriptor` 事件。该事件不携带 `surfaceOp`,不进入模型历史,并随该轮次的 flush 一并进入持久化存储。当压缩替换 surface 历史时,仅追加日志仍保留这个不属于 surface 的事件。只有在加载已知 child id 对应的 child 会话后能得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 - -版本化描述符包含 subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果契约,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`seedLength` 和 `delegationDepth` 的权威信息,持久化 child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。 - -从持久化存储恢复不能依赖旧 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。本提案删除 `SubagentRun.resume?()`:run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。本提案还将现有 `SubagentRun.sendMessage?()` 功能改名为 `SubagentRun.steer?()`,以免其严格的仅在线契约与服务编排或面向模型的工具混淆。 - -`SubagentControlService.resume()` 会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并创建 Task。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建,并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。 - -后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。child 创建、首轮持久化或描述符持久化失败时,系统会 dispose 所有已发布的 run,并将已经创建的 Task 结算为 `failed`;模型通过普通 Task 完成通知或 `task_output` 路径观察该失败。第一版会在当前已加载的 parent 作用域下重建进程内 spawn 和 fork 组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 - -TODO(ACP 继续执行):将远端 ACP session id 作为提供方专用描述符数据持久化,并实现 `AcpProvider.resume?()`,依次执行 spawn、initialize、`loadSession` 和 prompt。初始 ACP run 必须检查 `initialize.agentCapabilities.loadSession`,恢复后的每个进程必须使用同一个持久化后端;`loadSession` 回放的历史消息不得计入新激活的输出。由于 ACP 的加载支持是按 child 协商的,不能仅根据提供方是否存在该方法来确定,因此该后续工作还必须定义 start 结果如何声明单个 child 支持继续执行,之后才能将 ACP child 写入持久化目录。 - -### 结果与通知所有权 - -每次可继续 child 激活都恰好拥有一个 Task 和一个 `TaskOutcome`,无论第一条消息由 parent 还是用户提供。只要 Task 尚未标记为已报告,通用 Task 报告契约最多会向保留的 parent owner 注入一条主动完成通知;读取、等待和取消都可能抑制该通知。发送到运行中激活的消息会加入该激活,不会创建第二个 Task 或第二份结果。child transcript 是面向用户的详细记录;Task 输出是面向 parent 的最终结果。 - -Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可在重启后恢复,但不会恢复中断的 Task、其结果或通知。持久化 Task 恢复属于另一个问题。 - -### 实现边界 - -一个实现 PR 会交付本提案:稳定 child id 的分配与提供方交接、child 会话描述符事件、`SubagentControlService`、进程内提供方从持久化存储恢复、现有后台委派路由、严格的 spawn/fork steering、活跃 run 关联、用户消息路由,以及单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包及其 `send_message` 工具。parent 到 child 的枚举与 `list_agents` 使用这份持久化 child handle 契约,但仍是单独的功能和 PR。解决上述按 child 声明支持的契约后,再通过单独的提供方改动支持 ACP 继续执行。 - -## 已考虑的替代方案 - -**在 Task 结算后保留所有后台 child。** 这是 Codex 风格的常驻会话模型:发送后续消息成本较低,但历史 child 会持续占用 agent 作用域、会话内存、监听器和提供方资源,直至显式常驻数量上限或淘汰策略将其移除。逐激活 dispose 使用持久化作为继续执行边界,同时保留当前的资源上限。 - -**允许用户轮次不使用 Task。** parent 消息加入此类轮次后,没有对应的 Task 结果或完成通知;UI 取消对 parent 所发消息的影响也不明确。让每次激活都拥有一个 Task,可使完成与取消成为 child 轮次的属性,而不是初始调用方的属性。 - -**在 child 会话整个生命周期内复用一个 Task。** 终态 Task 无法自然地再次进入运行状态,一个结果也无法表示多个轮次。每次激活创建新 Task 可以保留通用 Task 契约。 - -**为每条消息创建 Task。** 发送到现有 run 的消息会加入已有轮次,不产生独立的最终结果;为这类消息创建 Task,会重复当前 Task,或报告一个它并不拥有的结果。只有启动新激活的消息才会创建 Task。 - -**拆分 `send_message` 与 `follow_up`。** 两个严格操作会向模型暴露实现状态差异,却无法消除 child 已停止时的竞态。单一操作采用 Claude Code 模型:向运行中的工作发送消息,或恢复一个由新 Task 支撑的生命周期。 - -**在已 dispose 的 run 上保留 `resume?()`。** 如果仅为调用 `resume()` 而保留已 dispose 的 `SubagentRun`,旧 run 会同时充当持久化 child handle,而且进程重启后无法重建该对象。由服务分发、提供方重建,可明确表达持久化边界。 - -**将控制编排放在 `SubagentService` 上。** 这样一个服务就能查找描述符、关联 Task 并分发提供方,但会迫使不感知集合的提供方 seam 依赖某个消费方的持久化与 Task 策略。单独的控制服务让前台及不使用 Task 的消费方可以复用 start/resume 传输,同时为工具和 UI 提供统一的编排路径。 - -**增加显式激活阶段。** 公开的 `starting`/`running`/`settling` 状态可以准确描述准入和清理,但会引入第一版实现并不需要的生命周期协议。暂缓实现的 promise 预留无需暴露这些阶段,即可消除进程内重复的 cold resume。 - -## 验收标准 - -- 初始及恢复后的可继续激活都会创建新 Task,并在该 Task 进入终态前 dispose 对应 run。 -- 打开持久化 child 仅用于展示时,不会创建 agent 激活;在 parent 已加载时,用户输入会启动或加入一个由 Task 支撑的激活。 -- 用户消息和 parent 消息发送到同一个运行中激活后,共享其 Task 结果和取消结果。 -- 取消用户启动的激活会中止并 dispose 对应 run,将 Task 结算为 `killed`;其完成通知遵循通用的至多一次报告契约,并且在 Task 已标记为已报告时可能被抑制。 -- 从持久化存储恢复的 Task 在描述符查找前就持有其 AbortSignal;查找描述符或执行提供方恢复期间发生取消时,系统不得在之后发布 run,若 run 已发布则会取消它。Task 只有在回滚或 dispose 完成、激活完全停稳后,才结算为 `killed`。 -- 用户界面适配器在接受 child 输入前会附加 Task 控制面;缺少控制面时明确失败,而不会启动未受跟踪的工作。 -- `send_message` 向运行中的 child 发送消息时不会创建 Task;向已停止的 child 发送消息时,会从持久化存储恢复并创建新的 Task 激活。 -- `send_message` 会以 `steered` 报告消息已加入现有 Task,或以 `started` 报告已启动新 Task,并携带相应 task id;失败时会报告消息未送达。 -- 初始可继续委派在创建 Task 前分配 child id,通过提供方发布路径传递该 id,并向模型返回稳定的 child id 与当前激活的 Task id。 -- spawn 和 fork 实现严格的 `SubagentRun.steer` 行为;检查运行状态与调用 `Agent.steer()` 之间不存在异步边界,在线消息不会 fallback 到未受跟踪的 Agent 轮次。 -- 严格 steering 在与 Task 结算的竞态中失败时,`send_message` 会报告消息未送达,而且不会在该次调用中从持久化存储恢复。 -- `SubagentRun` 不提供从持久化存储恢复的操作;`SubagentControlService.sendMessage()` 将活跃消息分发至 `run.steer?()`,将非活跃消息经由底层 `SubagentService.resume()` 分发至 `SubagentProvider.resume?()`。 -- `SubagentRun.sendMessage?()` 到 `steer?()` 的重命名和后台激活路由,会在同一 PR 中同步更新 seam 模块 JSDoc、各包 README、core-data-structures 目录,以及 `tool-subagent` 中 `settleRun` 的所有权文档和测试。 -- `SubagentService` 不感知 Task 与持久化描述符;`SubagentControlService` 负责可继续激活、鉴权、按已知 child id 查找描述符,以及工具和 UI 消费方使用的 Task/run 关联。 -- 每个受支持的可继续 child 轮次都会在等待提供方之前安装 Task 关联,并保留该关联直到 run dispose 完成;按 id 路由会拒绝存活的 `ctx.agents.get(childId)`,除非关联已存在,且其 `run.localAgent` 就是该 Agent。 -- parent 恢复后,系统可以对已知的持久化 child id 鉴权,并在恢复后的 parent 作用域下,以等价的声明式组合配置按需重建该 child;恢复 fork 时只使用 child 的持久化 transcript,绝不重新 fork parent 的当前历史。 -- 描述符输入会在创建 Task 前建立快照;带版本、对模型隐藏的描述符事件位于 child 会话轮次内,不属于 surface,在压缩后仍保留,并且只有在 child header 通过直接 parent 鉴权后才会被归并。描述符省略 `subagentDepth`,恢复时的深度以持久化 header 中的值为单调下界。 -- 描述符 JSON 无效会拒绝工具调用且不创建 Task,异步 child 创建或描述符持久化失败则会 dispose 对应 run,并将已经返回的 Task 结算为 `failed`。 -- 按提供方绑定的委派工具仍位于 `@deepseek-ai/dsh-tool-subagent`;全局命名的 `send_message` 工具由 `@deepseek-ai/dsh-tool-subagent-control` 注册一次。 -- 每次激活只产生一个 Task 结果和至多一条现有 Task 主动完成通知;读取、等待或取消可能抑制该通知,steering 和 subagent 层不会添加重复通知。 -- 测试记录已停止 child 的并发准入并非原子操作:一个相同会话的发布成功,失败的 Task 进入失败状态,且其消息不会被报告为已送达。 -- 无密钥包测试覆盖 Task 所有权、dispose 顺序、用户启动和取消、运行中消息、持久化后续轮次、描述符拒绝与回滚、按已知 id 重建、作用域重建,以及所有终态下的清理。面向模型的工具及 transcript 变更具有可运行的快照覆盖。 - -## 风险 - -- 每次完成结算后的后续轮次都需要承担持久化加载和作用域 setup 成本。持久化不可用或存储的组合配置无法重建时,可继续 child 的创建会明确失败。 -- 两个调用方可能同时观察到 child 已停止,并启动相互竞争的恢复。agent 注册表会阻止相同会话的重复发布,但失败的 Task 不会送达其消息。消息也可能与取消、终态发布或 run dispose 发生竞态。第一版不承诺原子准入或恰好执行一次语义;暂缓实现的进程内 promise 预留无需公开生命周期状态机,即可消除重复的 cold resume。 -- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。控制服务会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentControlService.sendMessage()` 提交用户输入。 -- 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。 -- 用户交互要求 Task 注册表中作为 owner 的那个 parent agent 实例保持存活,因为 dispose owner 会取消并移除其 Task。用户交互还要求附加 Task 控制面。若要单独与 child 交互,后续必须将 Task 访问所有权与持久化通知目标分离。 -- 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、持久化失败,或进程在 child 首次 flush 之前退出,都可能留下 unmaterialized child id;按 id 的控制操作会报告该 id 不可用,持久化枚举也不会列出它,而不会追溯修改工具返回结果。 -- 将显式组合字段持久化到 child 日志后,其无损 JSON 与兼容性契约便成为恢复契约的一部分。后续如需支持其他组合配置输入,必须明确更改描述符版本,不能隐式持久化可通过声明合并扩展的 `AgentOptions` 字段。 -- Task 记录和活跃 run 关联位于进程内,而 child 会话具有持久性。重启会恢复会话,但不会恢复进行中的工作或其 Task 通知。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e305eb5f42..49febba2a9 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 75340b6fb3e4e109974bcb9d9ccabec004d853e1 -architecture.zh.md: 0955a40f3571fe9146c75a6b1094f945fcc5219c +architecture.md: 0e78d7f9157e55ab1c5b6f518ef723e61237446e +architecture.zh.md: 27498c0d36ea54e6c952e0c1264b191d1448a554 diff --git a/docs/architecture.md b/docs/architecture.md index 75340b6fb3..0e78d7f915 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,6 +39,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction, optional model-free result pruning | | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | +| `ctx.subagentControl` | [`subagent/`](../packages/subagent/README.md) | continuable-child Task-backed activation and steer-or-resume routing | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 0955a40f35..27498c0d36 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -39,6 +39,7 @@ | `ctx.web` | [`web/`](../packages/web/README.md) | 搜索与抓取提供方注册表 | | `ctx.compact`,`ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction)和可选的无模型结果裁剪 | | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方 | +| `ctx.subagentControl` | [`subagent/`](../packages/subagent/README.md) | 可继续子 agent 的 Task 化 activation,以及 steer 或恢复路由 | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | 落日志的 plan 协作状态 | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index d31ee37d8e..946091c2c7 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -136,6 +136,8 @@ flowchart LR pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] pkg_tool_ralph["tool-ralph"] + svc_subagentControl["ctx.subagentControl
Continuable-subagent control service"] + pkg_tool_subagent_control["tool-subagent-control"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
Background task registry"] pkg_tasks_local["tasks-local"] @@ -223,6 +225,7 @@ flowchart LR pkg_storage_domain --> svc_storageDomain pkg_storage_json --> svc_storage pkg_storage_sqlite --> svc_storage + pkg_subagent --> svc_subagentControl pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents @@ -311,6 +314,8 @@ flowchart LR svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain svc_storageDomain --> pkg_workspace + svc_subagentControl --> pkg_tool_subagent + svc_subagentControl --> pkg_tool_subagent_control svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent svc_subprocess --> pkg_bash_local @@ -390,6 +395,7 @@ flowchart LR | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route. | +| `ctx.subagentControl` | `core` | [`subagent`](../packages/subagent/subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | - | Binds one durable child session to Task-backed activations over ctx.subagents; tool-subagent starts continuable background children and tool-subagent-control delivers follow-up messages. | | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ad12c61134..7980ef4136 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1928,7 +1928,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:24`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:27`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-tasks` @@ -2343,10 +2343,12 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) +- `@deepseek-ai/dsh-subagent-control` — requires `subagents` · `tasks` · `agents` ([`packages/subagent/subagent-control/src/index.ts`](../packages/subagent/subagent-control/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) - `@deepseek-ai/dsh-tasks-local` ([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) +- `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagentControl` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 1f9386fe39..fa8a961f19 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -795,7 +795,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:150`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -812,7 +812,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:114`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:124`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -827,7 +827,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:120`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:130`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -849,7 +849,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:131`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:141`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22bc6343ea..0e83259ea2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1946,6 +1946,50 @@ async closeAll(): Promise Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/storage/storage-domain/src/index.ts) +## `ctx.subagentControl` — `SubagentControlService` + +The continuable-subagent orchestration service. Tool schema and UI adapters are consumers of this one contract: parent and human messages route through sendMessage and share one activation result and cancellation boundary, while foreground one-shot delegation keeps calling `ctx.subagents.start()` directly. + +```ts cordis-catalog +/** + * Start a continuable background child: allocate its stable session id, + * snapshot its durable descriptor, and register the initial activation's + * Task. A synchronous validation failure (a non-JSON descriptor input, + * missing persistence, Task preflight) throws without creating a Task; the + * method otherwise returns both identities immediately, without waiting for + * child publication or descriptor durability. Asynchronous startup failure + * settles the returned Task as `failed` (or `killed` when cancelled) after + * any published run is disposed, which can leave an unmaterialized child id + * that later by-id operations report as unavailable. + * @param spec - provider, Task label, and the delegation request. + * @returns the stable child id and the initial activation's Task id. + */ +startContinuable(spec: ContinuableStartSpec): ContinuableStart + +/** + * Deliver one message to a known continuable child: steer its running + * activation, or cold-resume the durable session into a fresh Task-backed + * activation. The two routes are reported distinctly so timing-dependent + * routing is observable. A throw means the message was NOT delivered — in + * particular, losing a race with Task settlement does not fall through to + * cold resume within the same call; a later retry after Task terminal may + * start the next activation. The started Task owns descriptor lookup and + * direct-parent authorization (its AbortSignal exists before that lookup), + * so an unknown, foreign, or descriptor-less child settles the started Task + * as `failed` with a detail reporting the id as unavailable. + * @param parent - the live parent agent sending the message (model tool or + * human adapter); Task access is authorized by its session id. + * @param childId - the stable child session id. + * @param message - the content to deliver. + * @returns whether the message `steered` the existing Task or `started` a new one. + */ +sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult +``` + +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) + +Source: [`packages/subagent/subagent-control/src/index.ts:152`](../../packages/subagent/subagent-control/src/index.ts) + ## `ctx.subagents` — `SubagentService` Named provider registry and capability-checked start surface. @@ -1983,11 +2027,23 @@ list(): string[] * @returns the ready holder-owned run. */ async start(name: string, request: SubagentStartRequest): Promise + +/** + * Resume a persisted continuable child through the named provider's + * `resume` capability, with the same run lifecycle observation as + * {@link start}. The caller (the control service) has already loaded the + * child, folded its descriptor, and authorized the parent; this method owns + * only capability-checked dispatch. + * @param name - the provider recorded in the child's descriptor. + * @param request - the fully resolved resume request. + * @returns the fresh holder-owned run for the resumed activation. + */ +async resume(name: string, request: SubagentResumeRequest): Promise ``` -Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) +Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentResumeRequest](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:181`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:191`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 2497dbab9c..ec69056602 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,13 +4,13 @@ English | [中文](subagent.zh.md) The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumer is [dsh-tool-subagent](../../packages/subagent/tool-subagent). The proposal and rationale: [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the global `send_message`). Continuable-child orchestration lives on `ctx.subagentControl` in [dsh-subagent-control](../../packages/subagent/subagent-control). The proposals and rationale: [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) and [the continuable background subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md). Source: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) ## Two kinds of capability, discovered two ways -A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features (steering, resume) are instead optional methods on [`SubagentRun`](#a-live-run-subagentrun) — the method's presence IS the capability, and TS narrowing is the discovery mechanism. +A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features are instead optional methods whose presence IS the capability, with TS narrowing as the discovery mechanism: strict live steering is [`SubagentRun.steer`](#a-live-run-subagentrun) and persisted cold resume is [`SubagentProvider.resume`](#the-provider-seam-subagentprovider). ```ts type-equiv /** @@ -18,9 +18,10 @@ A provider advertises its **start-time** features on a static descriptor the ser * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent * degradation" rule). These static flags cover features needed before a run exists; runtime - * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence - * is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option: - * `depthLimit` to `maxDepth`; the other names match. + * capabilities are optional methods whose presence is the capability — strict live steering + * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each + * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to + * `maxDepth`; the other names match. */ interface SubagentCapabilities { readonly outputSchema: boolean @@ -88,11 +89,71 @@ interface SubagentStartRequest { * persona (strict `{{…}}` interpolation against the registered variables). */ readonly persona?: string + /** + * Continuable-child intent, resolved by the control service before start. + * The provider MUST publish exactly `sessionId` as the child identity + * instead of allocating one internally, and MUST append the snapshotted + * `descriptor` as the child's turn-enclosed `subagent/descriptor` event + * before its first request. Requires {@link SubagentProvider.resume} (the + * continuation capability); the service rejects the request otherwise. + */ + readonly continuation?: SubagentContinuation } ``` `signal` is the single cancellation channel before and after readiness. The [subagent composition-controls Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the persona, live global-tool filter, absolute-depth, and visibility-not-authority rationale. +## Continuable children: `SubagentContinuation` and `SubagentResumeRequest` + +A **continuable background subagent** is a durable child session with a series of Task-backed activations. `ctx.subagentControl` (`SubagentControlService` in [dsh-subagent-control](../../packages/subagent/subagent-control)) allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the resolved start request; the provider publishes exactly that id and appends the descriptor inside the child's first turn. On follow-up, the control service loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches a fully resolved resume request through `SubagentService.resume()` to `SubagentProvider.resume()`. The seam stays Task- and persistence-agnostic — descriptor lookup and Task association live only in the control service. `startContinuable()` returns a `ContinuableStart` (both identities), and `sendMessage()` returns a `SendMessageResult` reporting whether the message `steered` the running activation's existing Task or `started` a fresh one. + +```ts type-equiv +/** + * The resolved continuable-child identity and durable composition record a + * control-service caller attaches to a start request. + */ +interface SubagentContinuation { + /** Control-allocated stable child session id, published verbatim. */ + readonly sessionId: SessionId + /** Snapshotted descriptor persisted in the child log for cold resume. */ + readonly descriptor: SubagentDescriptorData +} +``` + +```ts type-equiv +/** + * What a caller asks for when resuming a persisted continuable child. The + * control service loads the child log, folds and authorizes its descriptor, + * and passes this fully resolved request to + * {@link SubagentService.resume}, which dispatches to + * {@link SubagentProvider.resume}. The provider reconstructs the declared + * composition under the live parent's scope and drives one turn with `prompt`. + */ +interface SubagentResumeRequest { + /** The persisted child session id to resume. */ + readonly sessionId: SessionId + /** The follow-up message that starts the resumed activation's turn. */ + readonly prompt: ContentBlock[] + /** + * The live parent agent — the direct parent recorded in the persisted child + * header. In-process backends reconstruct the child under this agent's + * currently loaded scope. + */ + readonly parent: Agent + /** + * Activation-owned cancellation signal, created before descriptor lookup. + * Same pre/post-publication contract as {@link SubagentStartRequest.signal}: + * an abort before publication rejects after rollback quiescence, and an + * abort afterward cancels the published child turn. + */ + readonly signal: AbortSignal + /** The folded durable descriptor whose composition the provider reconstructs. */ + readonly descriptor: SubagentDescriptorData +} +``` + +The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) snapshots explicit fields — provider name, resolved child `agentOptions.provider`/`model`, optional `persona`/`toolFilter` — never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (an activation's result contract, not durable composition). The `subagent/descriptor` event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. + ## The terminal result: `SubagentResult` The outcome of a run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. @@ -142,7 +203,7 @@ interface SubagentStopReasonMap { ## A live run: `SubagentRun` -`SubagentRun` is the consumer-owned handle for a ready child. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. Optional `sendMessage` and `resume` methods advertise their runtime capabilities by presence. +`SubagentRun` is the consumer-owned handle for a ready child — one disposable activation, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. The optional strict `steer` method advertises live delivery by presence; cold resume deliberately does NOT live here (a disposed run cannot be reconstructed after restart) — it is `SubagentProvider.resume`. ```ts type-equiv /** @@ -177,15 +238,16 @@ interface SubagentRun { */ dispose(): Promise /** - * OPTIONAL (steering capability): send additional content to the running - * child between steps. Present only on providers that support live steering. + * OPTIONAL (strict live-steering capability): deliver additional content to + * the actively running child turn. STRICT means delivery joins the observed + * turn or fails — the implementation must synchronously require the child to + * be running with no asynchronous boundary before delivery, and must not + * fall back to a queue path that could start a new, untracked turn after + * this run has settled. Throws when the child is not running. A run + * represents one disposable activation, so it has no cold-resume operation; + * resuming a settled child goes through {@link SubagentProvider.resume}. */ - sendMessage?(content: ContentBlock[]): void - /** - * OPTIONAL (resume capability): send a follow-up task to a settled child, - * continuing its session, and return a fresh run for the continuation. - */ - resume?(content: ContentBlock[]): Promise + steer?(content: ContentBlock[]): void } ``` @@ -221,10 +283,21 @@ interface SubagentProvider { * promise rejects. Ownership transfers to the caller only on fulfillment. */ start(request: SubagentStartRequest): Promise + /** + * OPTIONAL (continuation capability): reconstruct a persisted continuable + * child from its own transcript and declared descriptor, drive one + * follow-up turn, and return a fresh run. Method presence is the capability + * — the service rejects `resume` dispatch and continuable starts on + * providers without it. Same publication contract as {@link start}: if + * reconstruction fails or `request.signal` aborts before fulfillment, the + * provider rolls its creation transaction back to quiescence before + * rejecting; after fulfillment the same signal cancels the published run. + */ + resume?(request: SubagentResumeRequest): Promise } ``` -`start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. +`start()` fulfills only with a ready run; `resume()` shares the same publication and lifecycle-observation contract. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. ## In-process backends: depth and seed diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f2007c814a..ce1e0f1310 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,7 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | | `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:434`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | @@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:131`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:150`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:124`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:141`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 5ba907f02f..d880453727 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,11 +65,13 @@ flowchart TD subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] pkg_subagent_acp["subagent-acp"] + pkg_subagent_control["subagent-control"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] pkg_subagent_spawn["subagent-spawn"] pkg_tool_subagent["tool-subagent"] + pkg_tool_subagent_control["tool-subagent-control"] end subgraph group_web["packages/web"] pkg_tool_web["tool-web"] @@ -890,6 +892,13 @@ flowchart TD pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subprocess + pkg_subagent_control --> pkg_agent + pkg_subagent_control --> pkg_invariants + pkg_subagent_control --> pkg_llm + pkg_subagent_control --> pkg_session + pkg_subagent_control --> pkg_session_persistence + pkg_subagent_control --> pkg_subagent + pkg_subagent_control --> pkg_tasks pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -899,12 +908,6 @@ flowchart TD pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools pkg_subagent_inprocess --> pkg_user_approval - pkg_tool_subagent --> pkg_agent - pkg_tool_subagent --> pkg_invariants - pkg_tool_subagent --> pkg_llm - pkg_tool_subagent --> pkg_subagent - pkg_tool_subagent --> pkg_tasks - pkg_tool_subagent --> pkg_tools pkg_repository_plugin --> pkg_invariants pkg_repository_plugin --> pkg_mcp_client pkg_repository_plugin --> pkg_paths @@ -1011,6 +1014,18 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_invariants + pkg_tool_subagent --> pkg_llm + pkg_tool_subagent --> pkg_subagent + pkg_tool_subagent --> pkg_subagent_control + pkg_tool_subagent --> pkg_tasks + pkg_tool_subagent --> pkg_tools + pkg_tool_subagent_control --> pkg_invariants + pkg_tool_subagent_control --> pkg_llm + pkg_tool_subagent_control --> pkg_session + pkg_tool_subagent_control --> pkg_subagent_control + pkg_tool_subagent_control --> pkg_tools pkg_jsonrpc --> pkg_agent pkg_jsonrpc --> pkg_invariants pkg_jsonrpc --> pkg_llm @@ -1211,8 +1226,8 @@ flowchart TD | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`subagent-control`](../packages/subagent/subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | @@ -1225,6 +1240,8 @@ flowchart TD | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-control`](../packages/subagent/subagent-control), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent-control`](../packages/subagent/subagent-control), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b02dd1d6be..ec0e0c7a3e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -516,6 +516,23 @@ Source: [`packages/core/session/src/types.ts:218`](../packages/core/session/src/ Source: [`packages/core/session/src/types.ts:216`](../packages/core/session/src/types.ts) +### `subagent/*` + +#### `subagent/descriptor` — log-only + +```ts persistence-catalog +/** + * Durable declared composition of a continuable subagent child, appended + * once by the establishing provider inside the child's initial turn, + * before its first request. Log-only: it carries no `surfaceOp`, never + * enters model history, and the append-only log retains it when + * compaction replaces surface history. + */ +'subagent/descriptor': SubagentDescriptorData +``` + +Source: [`packages/subagent/subagent/src/descriptor.ts:32`](../packages/subagent/subagent/src/descriptor.ts) + ### `todo/*` #### `todo/write` — log-only diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 7d6fa79dea..0a316bc636 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -31,6 +31,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent-control` | `send_message` | `ctx.tools`, `ctx.subagentControl` | `tool/call`, `tool/result`, `child session events through the control service` | - | The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -1116,7 +1117,7 @@ The five read-only tools hide provider cursors and authorize every result from t ### `subagent` -Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. +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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. ```json { @@ -1132,7 +1133,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -1146,6 +1147,36 @@ Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/to The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`. +## `@deepseek-ai/dsh-tool-subagent-control` + +### `send_message` + +Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered. + +```json +{ + "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" + ] +} +``` + +Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) + +The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once. + ## `@deepseek-ai/dsh-tool-tasks` ### `task_kill` diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index b4a3236920..22adf09555 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -39,6 +39,10 @@ flowchart LR cfg --> plugin_acp_subagent_spawn plugin_acp_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_acp_subagent_fork + plugin_acp_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"] + cfg --> plugin_acp_subagent_control + plugin_acp_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] + cfg --> plugin_acp_tool_subagent_control plugin_acp_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_acp_tool_subagent plugin_acp_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] @@ -79,6 +83,8 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `subagent-control` | `@deepseek-ai/dsh-subagent-control` | +| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 6f9f000182..e45c56c92d 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -96,6 +96,15 @@ config: providerName: fork +# Continuable background children: the control service owns durable child ids +# and Task-backed activations; the separately loaded control tool registers the +# one global `send_message` shared by both delegation tools. +- id: subagent-control + name: '@deepseek-ai/dsh-subagent-control' + +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index b652f09931..aa4bad392c 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -213,6 +213,11 @@ const SCENARIOS: Scenario[] = [ { name: 'subagent-multi', hasModelTurn: true, recorded: true }, { name: 'subagent-fork', hasModelTurn: true, recorded: true }, { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, + // Authored continuable-subagent transcript: a background delegation returns + // both the durable subagent id and its task id, task_output collects the + // child result after settlement, and send_message to an unknown subagent id + // starts a follow-up task that settles failed with the id unavailable. + { name: 'subagent-continuable', hasModelTurn: true, recorded: false }, { name: 'subagent-depth-two-rejection', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 97f0561a37..7df756ee99 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -110,27 +110,34 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; + /** Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered. */ + send_message: { + /** The subagent id returned when the background subagent was started. */ + subagent_id: string; + /** The message to deliver to the subagent. */ + message: string; + } & Record; /** 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. */ skill: { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ + /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ + /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ @@ -309,6 +316,10 @@ interface ToolOutputMap { }[]; totalLines: number; }; + send_message: { + route: "steered" | "started"; + taskId: string; + }; skill: { name: string; provider: string; @@ -327,6 +338,7 @@ interface ToolOutputMap { subagent: { kind: "background"; taskId: string; + subagentId?: string; } | { kind: "foreground"; runId: string; @@ -335,6 +347,7 @@ interface ToolOutputMap { subagent_fork: { kind: "background"; taskId: string; + subagentId?: string; } | { kind: "foreground"; runId: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 314b24e2be..5f8e31fe9b 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -237,6 +237,27 @@ ] } }, + { + "name": "send_message", + "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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.", @@ -255,7 +276,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -269,7 +290,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -280,7 +301,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -294,7 +315,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index b61d7bf623..1a7e813d7c 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -180,6 +180,27 @@ ] } }, + { + "name": "send_message", + "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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.", @@ -198,7 +219,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -212,7 +233,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -223,7 +244,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -237,7 +258,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 150c53d68e..31e8cdce23 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -93,27 +93,34 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; + /** Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered. */ + send_message: { + /** The subagent id returned when the background subagent was started. */ + subagent_id: string; + /** The message to deliver to the subagent. */ + message: string; + } & Record; /** 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. */ skill: { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ + /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ + /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ @@ -280,6 +287,10 @@ interface ToolOutputMap { }[]; totalLines: number; }; + send_message: { + route: "steered" | "started"; + taskId: string; + }; skill: { name: string; provider: string; @@ -298,6 +309,7 @@ interface ToolOutputMap { subagent: { kind: "background"; taskId: string; + subagentId?: string; } | { kind: "foreground"; runId: string; @@ -306,6 +318,7 @@ interface ToolOutputMap { subagent_fork: { kind: "background"; taskId: string; + subagentId?: string; } | { kind: "foreground"; runId: string; diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 9b5925605c..517c9b1d71 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -196,6 +196,27 @@ ] } }, + { + "name": "send_message", + "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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.", @@ -214,7 +235,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -228,7 +249,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -239,7 +260,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -253,7 +274,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 8e093db8bd..abc3e13256 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -159,6 +159,27 @@ ] } }, + { + "name": "send_message", + "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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.", @@ -177,7 +198,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -191,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -202,7 +223,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -216,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index beb93c6b53..2ac976d621 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -159,6 +159,27 @@ ] } }, + { + "name": "send_message", + "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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": "session_event_read", "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", @@ -381,7 +402,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -395,7 +416,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -406,7 +427,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -420,7 +441,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/input.json b/examples/acp-agent/tests/snapshots/subagent-continuable/input.json new file mode 100644 index 0000000000..7fd4a2c3e4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Collect its result with task_output using the task id from the acknowledgement and wait: true. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.'. 4. Collect the task it started with task_output and wait: true, and observe that it failed. 5. Reply with the single word DONE. Do not use the bash tool." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl new file mode 100644 index 0000000000..c349369a85 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -0,0 +1,17 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1789000000000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1789000000001,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"5eabc0cb-6297-4988-92d9-554fb1cfdab7"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1789000000001,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"subagent/descriptor","seq":3,"time":1784795691405,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"user/message","seq":4,"time":1785517567401,"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":"57bfffb1-f18b-4e29-aaca-26ecaea51574"},"surfaceOp":"append"} +{"type":"step/start","seq":5,"time":1785517567401,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":6,"time":1785517567401,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":7,"time":1785517567401,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":8,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":10,"time":1784795691405,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":11,"time":1785517567410,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":1785517567410,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":13,"time":1785517567410,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"156cd267-c1e6-4030-b317-dc2936120f4a"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"step/end","seq":14,"time":1785517567410,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":15,"time":1785517567411,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl new file mode 100644 index 0000000000..e9e859d905 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -0,0 +1,57 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1789000000000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1789000000001,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Collect its result with task_output using the task id from the acknowledgement and wait: true. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.'. 4. Collect the task it started with task_output and wait: true, and observe that it failed. 5. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"42a76bb1-818e-427e-8037-76b33c3a5c1f"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1789000000001,"data":{"title":"Follow these steps exactly, then","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":3,"time":1785517567360,"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":"6dee8203-be1c-4287-86f3-db1ea0197c19"},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1785517567360,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785517567361,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":6,"time":1785517567361,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":7,"time":1789000000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} +{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} +{"type":"assistant/chunk","seq":10,"time":1785517567370,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":11,"time":1785517567370,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":1785517567370,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"631fd641-46e9-4e62-965a-2fd7a87e2720"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":1785517567370,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} +{"type":"tool/result","seq":14,"time":1785517567380,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333 as task subagent-1"}],"isError":false}],"role":"user","id":"28d3f6cb-8934-4dcc-9cf2-7db87b0df06a"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1785517567380,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":16,"time":1785517567387,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":17,"time":1789000000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":18,"time":1789000000017,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_collect_1","name":"task_output","argumentsDelta":"{\"task_id\": \"subagent-1\", \"wait\": true}"}}} +{"type":"assistant/chunk","seq":19,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}}}} +{"type":"assistant/chunk","seq":20,"time":1785517567391,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":21,"time":1785517567391,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":22,"time":1785517567392,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fcea712-0e14-4f2d-909c-f7de70018053"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} +{"type":"tool/call","seq":23,"time":1785517567392,"data":{"turn":1,"step":2,"callId":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}} +{"type":"tool/result","seq":24,"time":1785517567419,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_collect_1"},"content":[{"type":"tool-result","toolCallId":"call_collect_1","content":[{"type":"text","text":"CHILD_OK\n[status: completed]"}],"isError":false}],"role":"user","id":"ae79571a-fa78-4de0-9614-a10b5223230c"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785517567419,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":26,"time":1785517567425,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":27,"time":1789000000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":28,"time":1789000000027,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_follow_up","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}} +{"type":"assistant/chunk","seq":29,"time":1789000000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} +{"type":"assistant/chunk","seq":30,"time":1785517567430,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":31,"time":1785517567430,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":32,"time":1785517567430,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5e78130e-5ae7-4ec9-ad34-9e2400a23ef0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} +{"type":"tool/call","seq":33,"time":1785517567431,"data":{"turn":1,"step":3,"callId":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} +{"type":"tool/result","seq":34,"time":1785517567438,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_follow_up"},"content":[{"type":"tool-result","toolCallId":"call_follow_up","content":[{"type":"text","text":"message started task subagent-2 continuing subagent 22222222-2222-4222-8222-222222222222"}],"isError":false}],"role":"user","id":"6a7a5d22-1172-4a10-9230-ec12aed58e5e"}},"sourceEventSeqs":[33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1785517567438,"data":{"turn":1,"step":3}} +{"type":"user/message","seq":36,"time":1785517567444,"data":{"content":[{"type":"text","text":"background task subagent-2 (subagent: Please continue.) finished [status: failed, SubagentControlError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]. Read its output with task_output."}],"source":{"kind":"plugin","plugin":"tool-tasks"},"role":"user","id":"32644e35-5ea1-4d29-8ef6-e09eb813781c"},"surfaceOp":"append"} +{"type":"step/start","seq":37,"time":1785517567444,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":38,"time":1789000000037,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":39,"time":1789000000038,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_collect_2","name":"task_output","argumentsDelta":"{\"task_id\": \"subagent-2\", \"wait\": true}"}}} +{"type":"assistant/chunk","seq":40,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}}}} +{"type":"assistant/chunk","seq":41,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":42,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":43,"time":1785517567453,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"31137fd0-a07c-4d5f-b847-6dbb33e86305"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} +{"type":"tool/call","seq":44,"time":1785517567454,"data":{"turn":1,"step":4,"callId":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}} +{"type":"tool/result","seq":45,"time":1785517567460,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_collect_2"},"content":[{"type":"tool-result","toolCallId":"call_collect_2","content":[{"type":"text","text":"(no new output)\n[status: failed, SubagentControlError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]"}],"isError":false}],"role":"user","id":"21807217-0a28-4369-868c-c2480398e883"}},"sourceEventSeqs":[44],"surfaceOp":"append"} +{"type":"step/end","seq":46,"time":1785517567460,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":47,"time":1785517567467,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":48,"time":1789000000047,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":49,"time":1789000000048,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":50,"time":1785517567471,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":51,"time":1785517567471,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":52,"time":1785517567471,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":53,"time":1785517567471,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fa9e88d9-d89c-4df7-85d5-0e4fd795ae69"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} +{"type":"step/end","seq":54,"time":1785517567472,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":55,"time":1785517567472,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index 01ac777a42..47439bfdb0 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -159,6 +159,27 @@ ] } }, + { + "name": "send_message", + "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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.", @@ -177,7 +198,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -191,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -202,7 +223,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -216,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index 70940f8907..d1a60f6f92 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -159,6 +159,27 @@ ] } }, + { + "name": "send_message", + "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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.", @@ -177,7 +198,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -191,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -202,7 +223,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -216,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ 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 d9a20e60c7..2a060ef30b 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"85750b5e-389a-4dfb-83e7-3341025692da"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-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 /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime 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":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"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 /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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":"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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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":1785460681625,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"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 5e7dd32bb6..b092257bbd 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2b9d695a-5ba1-4520-8130-d618bc1a4743"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-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 /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime 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":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"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 /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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":"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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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":1785460681788,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"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 1f9f94fa0c..487541609a 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 /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime 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":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"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 /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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":"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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 b187ff75cc..4da592774f 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":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"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":"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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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/examples/package.json b/examples/package.json index 97e7918361..fb105d2ad6 100644 --- a/examples/package.json +++ b/examples/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", + "@deepseek-ai/dsh-subagent-control": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", @@ -86,6 +87,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:*", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:*", "@deepseek-ai/dsh-tool-tasks": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2beb8a3c77..1657f0faa2 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -880,6 +880,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'subagentControl', + summary: 'The continuable-subagent orchestration service.', + methods: [ + { + signature: 'startContinuable(spec: ContinuableStartSpec): ContinuableStart', + jsDoc: '/**\n * Start a continuable background child: allocate its stable session id,\n * snapshot its durable descriptor, and register the initial activation\'s\n * Task. A synchronous validation failure (a non-JSON descriptor input,\n * missing persistence, Task preflight) throws without creating a Task; the\n * method otherwise returns both identities immediately, without waiting for\n * child publication or descriptor durability. Asynchronous startup failure\n * settles the returned Task as `failed` (or `killed` when cancelled) after\n * any published run is disposed, which can leave an unmaterialized child id\n * that later by-id operations report as unavailable.\n * @param spec - provider, Task label, and the delegation request.\n * @returns the stable child id and the initial activation\'s Task id.\n */', + }, + { + signature: 'sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult', + jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. A throw means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the content to deliver.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */', + }, + ], + }, { key: 'subagents', summary: 'Named provider registry and capability-checked start surface.', @@ -900,6 +914,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async start(name: string, request: SubagentStartRequest): Promise', jsDoc: '/**\n * Establish a ready child on the named provider. Capability and semantic\n * checks run before delegation. Provider ownership lasts until its promise\n * fulfills; a rejection therefore has no run for the caller to dispose and\n * emits no run lifecycle events.\n * @param name - the provider to use.\n * @param request - child prompt, parent, signal, and optional capabilities.\n * @returns the ready holder-owned run.\n */', }, + { + signature: 'async resume(name: string, request: SubagentResumeRequest): Promise', + jsDoc: '/**\n * Resume a persisted continuable child through the named provider\'s\n * `resume` capability, with the same run lifecycle observation as\n * {@link start}. The caller (the control service) has already loaded the\n * child, folded its descriptor, and authorized the parent; this method owns\n * only capability-checked dispatch.\n * @param name - the provider recorded in the child\'s descriptor.\n * @param request - the fully resolved resume request.\n * @returns the fresh holder-owned run for the resumed activation.\n */', + }, ], }, { @@ -1783,6 +1801,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContentBlockType', declaration: 'export type ContentBlockType = keyof ContentBlockMap;', }, + { + name: 'ContinuableStart', + declaration: 'export interface ContinuableStart {\n readonly childId: SessionId;\n readonly taskId: TaskId;\n}', + }, + { + name: 'ContinuableStartSpec', + declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly label: string;\n readonly request: Omit;\n}', + }, { name: 'CreateAgentOptions', declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', @@ -2331,6 +2357,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SearchResultView', declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;', }, + { + name: 'SendMessageResult', + declaration: 'export type SendMessageResult = {\n readonly route: \'steered\';\n readonly taskId: TaskId;\n} | {\n readonly route: \'started\';\n readonly taskId: TaskId;\n};', + }, { name: 'SendOptions', declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}', @@ -2651,21 +2681,33 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentCapabilities', declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', }, + { + name: 'SubagentContinuation', + declaration: 'export interface SubagentContinuation {\n readonly sessionId: SessionId;\n readonly descriptor: SubagentDescriptorData;\n}', + }, + { + name: 'SubagentDescriptorData', + declaration: 'export interface SubagentDescriptorData {\n readonly version: number;\n readonly provider: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}', + }, { name: 'SubagentProvider', - declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise;\n}', + declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise;\n resume?(request: SubagentResumeRequest): Promise;\n}', }, { name: 'SubagentResult', declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly stopReason: SubagentStopReason;\n}', }, + { + name: 'SubagentResumeRequest', + declaration: 'export interface SubagentResumeRequest {\n readonly sessionId: SessionId;\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly descriptor: SubagentDescriptorData;\n}', + }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n steer?(content: ContentBlock[]): void;\n}', }, { name: 'SubagentStartRequest', - declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}', + declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n readonly continuation?: SubagentContinuation;\n}', }, { name: 'SubagentStopReason', diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 1ab8bc2730..ed074df4a6 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index a875878075..0491b589c6 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/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/subagent/README.md -README.md: fed0c3d6b252f5eeb8355c3b544066765999120a -README.zh.md: 9bc187aa972bc92385d32fe787e2fd65b6ce8361 +README.md: 438907ea7de41842f900b050385f15feac7cc272 +README.zh.md: 87911216bc4e6b5f75e17ca2c58818725f66e7ec diff --git a/packages/subagent/README.md b/packages/subagent/README.md index fed0c3d6b2..438907ea7d 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -6,14 +6,16 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | Package | Role | ctx key | |---|---|---| -| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` | +| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary + the durable child descriptor | `ctx.subagents` | | `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — | -| `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | -| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | -| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | +| `subagent-spawn/` | In-process backend: a fresh child agent, with cold resume | (registers on `ctx.subagents`) | +| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) | +| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) | | `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | +| `subagent-control/` | Continuable-child orchestration: stable ids, descriptor lookup, Task-backed activation, steer-or-resume routing | `ctx.subagentControl` | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | +| `tool-subagent-control/` | The one globally named `send_message` follow-up tool over `ctx.subagentControl` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). `subagent-control` sits above the seam: it binds one durable child session to a series of disposable Task-backed activations, and both model tools and human-facing adapters route through its one contract. Tests replace only the child boundary with package-local fixtures. -The proposal and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md). +The proposals and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) and [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 9bc187aa97..87911216bc 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -6,14 +6,16 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | 包(package) | 角色 | ctx 键 | |---|---|---| -| `subagent/` | 抽象 subagent seam:具名提供方注册表与词汇 | `ctx.subagents` | +| `subagent/` | 抽象 subagent seam:具名提供方注册表、词汇与持久化子 agent 描述符 | `ctx.subagents` | | `subagent-inprocess/` | 共享进程内运行驱动器(不含提供方;每次运行使用一个清理 effect) | 无 | -| `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents`) | -| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents`) | -| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的子 agent | (注册到 `ctx.subagents`) | +| `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) | +| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) | +| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) | | `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | +| `subagent-control/` | 可继续子 agent 编排:稳定 ID、描述符查找、由 Task 支撑的 activation,以及 steer 或恢复路由 | `ctx.subagentControl` | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | +| `tool-subagent-control/` | 基于 `ctx.subagentControl`、全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools`) | -接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。`subagent-control` 位于该 seam 之上:它把一个持久化子会话绑定到一系列可 dispose、由 Task 支撑的 activation,模型工具和面向人的适配器都通过这份统一契约进行路由。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 -提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 +提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) 和 [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)。 diff --git a/packages/subagent/subagent-control/README.md b/packages/subagent/subagent-control/README.md new file mode 100644 index 0000000000..002613d508 --- /dev/null +++ b/packages/subagent/subagent-control/README.md @@ -0,0 +1,37 @@ +# @deepseek-ai/dsh-subagent-control + +The continuable-subagent control service (`ctx.subagentControl`): the one orchestration path that binds a durable child session to a series of disposable Task-backed activations. Model tools and human-facing adapters call the same contract; the low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic. + +## Activation lifecycle + +A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. + +`sendMessage(parent, childId, message)` owns steer-or-resume routing. A running activation receives live delivery through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` (`started`). Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. + +Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface). + +The activation association is process-local routing state, installed before any persistence or provider await and removed after run disposal and Task terminal publication. It is not a durable catalog: restart recovers the child session, not in-flight Tasks or their notifications. + +## Model Experience + +### Task completion and output + +#### What the model sees + +None directly, as this package registers no tool and no prompt text; the model observes continuable children through `@deepseek-ai/dsh-tool-subagent`'s background acknowledgement, `@deepseek-ai/dsh-tool-subagent-control`'s `send_message` results, and the generic task surface, whose outputs this service produces. + +#### Token effect + +None beyond the consuming tools' own results. + +#### KV Cache effect + +None; this service appends nothing to any model-visible sequence. + +## Known Limitations and Deferred Work + +- **Concurrent stopped-child admission is not atomic across awaits** — the synchronous association install admits one activation per child in this process, but a caller bypassing the control service can still race it; the Agent registry's same-id collision is the final backstop, and the losing Task fails with its message not delivered. +- **The association coordinates only one runtime** — concurrent resume from multiple processes needs a persistence-level lease or compare-and-set, which no backend offers yet. +- **Task records are process-local** — restart recovers the durable child session, not an interrupted Task, its result, or its completion notice; durable Task recovery is a separate concern. +- **Human interaction requires the exact live parent Agent** — Task access is fenced by the owner session and owner disposal cancels its Tasks; standalone child conversations belong to the interactive-side-sessions proposal, not this Task-owned lifecycle. +- **ACP children remain one-shot** — `AcpProvider.resume` and per-child continuation advertisement are deferred until the remote-session descriptor contract is resolved. diff --git a/packages/subagent/subagent-control/package.json b/packages/subagent/subagent-control/package.json new file mode 100644 index 0000000000..9522bca29e --- /dev/null +++ b/packages/subagent/subagent-control/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-subagent-control", + "description": "Continuable-subagent control service: Task-backed activation, durable child descriptors, and steer-or-resume message routing", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-tasks": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-fork": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts new file mode 100644 index 0000000000..fa1974eb22 --- /dev/null +++ b/packages/subagent/subagent-control/src/index.ts @@ -0,0 +1,442 @@ +/** + * Continuable-subagent control service (`ctx.subagentControl`): stable child + * ids, descriptor persistence and lookup by known child id, Task-backed + * activation, and steer-or-resume message routing. The low-level + * `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic; + * this service owns the policy that binds one durable child session to a + * series of disposable Task-backed activations. + * + * Every continuable activation — initial or resumed, parent- or human-started + * — has exactly one Task and one result. Task settlement awaits the child + * result, disposes the run, and only then records the outcome, so a terminal + * Task leaves the durable child session but no live child Agent. Cancellation + * targets the whole activation: parent and human messages that joined one + * turn share its result and its `killed` outcome. + * + * @module @deepseek-ai/dsh-subagent-control + */ + +import { randomUUID } from 'node:crypto' +import { Context, Service } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import { foldSubagentDescriptor, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks' + +declare module 'cordis' { + interface Context { + subagentControl: SubagentControlService + } +} + +/** Typed error for control-service routing, authorization, and delivery failures. */ +export class SubagentControlError extends HarnessError { + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'SubagentControlError' + } +} + +/** What a caller asks for when starting a continuable background child. */ +export interface ContinuableStartSpec { + /** The `ctx.subagents` provider to establish the child on. */ + readonly provider: string + /** One-line model-facing Task label (the delegation description). */ + readonly label: string + /** + * The delegation request. The service resolves the stable child id and the + * durable descriptor, then supplies the Task-owned cancellation signal and + * `continuation` itself. + */ + readonly request: Omit +} + +/** Identities returned by {@link SubagentControlService.startContinuable}. */ +export interface ContinuableStart { + /** The durable child session id, stable across activations. */ + readonly childId: SessionId + /** The initial activation's Task id. */ + readonly taskId: TaskId +} + +/** + * How {@link SubagentControlService.sendMessage} delivered a message: + * `steered` joined the running activation's existing Task without creating a + * Task of its own; `started` created a fresh Task that cold-resumes the + * durable child with the message. Failure is an exception, never a result — + * an undelivered message throws. + */ +export type SendMessageResult = + | { readonly route: 'steered'; readonly taskId: TaskId } + | { readonly route: 'started'; readonly taskId: TaskId } + +/** + * One child's current process-local activation: its Task and, after provider + * publication, its run. Installed before any provider or persistence await + * and removed only after run disposal and Task terminal publication. This + * exists solely so parent and human senders can find the same activation — it + * is not a durable catalog, admission reservation, or run-state machine. + */ +interface ActiveActivation { + /** Assigned in the same synchronous frame as the install, when the Task registers. */ + taskId: TaskId | undefined + /** Filled when the provider publishes; `undefined` while starting or resuming. */ + run: SubagentRun | undefined + /** Resolved by the completion listener when the Task's terminal snapshot is recorded. */ + readonly terminal: PromiseWithResolvers +} + +/** + * Map a child result to the task outcome: completed carries final text, + * aborted is killed, and every other reason is failed without partial output. + * @param result - child terminal result. + * @returns outcome for the `ctx.tasks` registration. + */ +export function runOutcome(result: SubagentResult): TaskOutcome { + switch (result.stopReason) { + case 'completed': + return { status: 'completed', output: finalText(result.output) } + case 'aborted': + return { status: 'killed' } + case 'error': + case 'max-tokens': + case 'refusal': + return { status: 'failed', detail: result.stopReason } + // Merge-extensible reasons remain failures with their raw detail. + default: + return { status: 'failed', detail: String(result.stopReason) } + } +} + +/** + * Await the child result, dispose the run, then return its task outcome. Result + * and disposal failures become `failed`; when both fail, both details survive. + * @param run - live run to settle and release. + * @returns outcome after child resources are released. + */ +export async function settleRun(run: SubagentRun): Promise { + let outcome: TaskOutcome + try { + outcome = runOutcome(await run.result) + } catch (error: unknown) { + outcome = { status: 'failed', detail: String(error) } + } + try { + await run.dispose() + } catch (error: unknown) { + const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; ` + return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` } + } + return outcome +} + +/** Flatten a child's final output blocks to the task's final text. */ +function finalText(blocks: ContentBlock[]): string { + return blocks + .filter((block): block is Extract => block.type === 'text') + .map(block => block.text) + .join('') +} + +/** + * The continuable-subagent orchestration service. Tool schema and UI adapters + * are consumers of this one contract: parent and human messages route through + * {@link sendMessage} and share one activation result and cancellation + * boundary, while foreground one-shot delegation keeps calling + * `ctx.subagents.start()` directly. + */ +export class SubagentControlService extends Service { + static inject = ['subagents', 'tasks', 'agents'] + + /** Child session id → its current activation. Process-local, never durable. */ + private activations = new Map() + + constructor(ctx: Context) { + super(ctx, 'subagentControl') + // Terminal publication is one of the two removal conditions. The exact + // Task id pins the resolution to this activation, never a later same-child one. + ctx.tasks.onTaskDone((snapshot) => { + for (const activation of this.activations.values()) { + if (activation.taskId === snapshot.id) activation.terminal.resolve() + } + }) + ctx.effect(() => () => { this.activations.clear() }, 'subagentControl.activations()') + } + + /** + * Start a continuable background child: allocate its stable session id, + * snapshot its durable descriptor, and register the initial activation's + * Task. A synchronous validation failure (a non-JSON descriptor input, + * missing persistence, Task preflight) throws without creating a Task; the + * method otherwise returns both identities immediately, without waiting for + * child publication or descriptor durability. Asynchronous startup failure + * settles the returned Task as `failed` (or `killed` when cancelled) after + * any published run is disposed, which can leave an unmaterialized child id + * that later by-id operations report as unavailable. + * @param spec - provider, Task label, and the delegation request. + * @returns the stable child id and the initial activation's Task id. + */ + startContinuable(spec: ContinuableStartSpec): ContinuableStart { + this.requirePersistence() + const childId = SessionId(randomUUID()) + const request = spec.request + // Snapshot before Task creation: invalid descriptor JSON rejects the call + // with no Task, and the detached value is what reaches the child log. + const agentProvider = request.agentOptions?.provider ?? request.parent.options.provider + const agentModel = request.agentOptions?.model ?? request.parent.options.model + const descriptor = snapshotSubagentDescriptor({ + provider: spec.provider, + ...agentProvider !== undefined ? { agentProvider } : {}, + ...agentModel !== undefined ? { agentModel } : {}, + ...request.persona !== undefined ? { persona: request.persona } : {}, + ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, + }) + const taskId = this.startActivation(childId, spec.label, request.parent, signal => + this.ctx.subagents.start(spec.provider, { + ...request, + signal, + continuation: { sessionId: childId, descriptor }, + })) + return { childId, taskId } + } + + /** + * Deliver one message to a known continuable child: steer its running + * activation, or cold-resume the durable session into a fresh Task-backed + * activation. The two routes are reported distinctly so timing-dependent + * routing is observable. A throw means the message was NOT delivered — in + * particular, losing a race with Task settlement does not fall through to + * cold resume within the same call; a later retry after Task terminal may + * start the next activation. The started Task owns descriptor lookup and + * direct-parent authorization (its AbortSignal exists before that lookup), + * so an unknown, foreign, or descriptor-less child settles the started Task + * as `failed` with a detail reporting the id as unavailable. + * @param parent - the live parent agent sending the message (model tool or + * human adapter); Task access is authorized by its session id. + * @param childId - the stable child session id. + * @param message - the content to deliver. + * @returns whether the message `steered` the existing Task or `started` a new one. + */ + sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult { + this.assertOwnership(childId) + const activation = this.activations.get(childId) + if (activation !== undefined) { + return { route: 'steered', taskId: this.steerActivation(activation, parent, childId, message) } + } + return { route: 'started', taskId: this.resumeActivation(parent, childId, message) } + } + + /** + * Synchronous ownership compare before any by-id routing: a live registry + * Agent outside the association — or different from the associated run's + * agent — was started by something else. Fail instead of adopting an idle + * Agent or attaching an untracked turn. + */ + private assertOwnership(childId: SessionId): void { + const live = this.ctx.agents.get(childId) + if (live === undefined) return + const activation = this.activations.get(childId) + if (activation === undefined) { + throw new SubagentControlError( + `subagent "${childId}" has a live agent outside control-service ownership; the message was not delivered`, + 'OWNERSHIP_CONFLICT', + ) + } + if (activation.run !== undefined && activation.run.localAgent !== live) { + throw new SubagentControlError( + `subagent "${childId}" registry agent is not the associated activation's agent; the message was not delivered`, + 'OWNERSHIP_CONFLICT', + ) + } + } + + /** Deliver to the running activation's Task through strict live steering. */ + private steerActivation( + activation: ActiveActivation, + parent: Agent, + childId: SessionId, + message: ContentBlock[], + ): TaskId { + const taskId = activation.taskId + /* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */ + if (taskId === undefined) { + throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') + } + // Owner-session authorization plus the live status for the strict check. + const snapshot = this.ctx.tasks.get(taskId, parent) + if (snapshot.status !== 'running') { + throw new SubagentControlError( + `subagent "${childId}" task ${taskId} is ${snapshot.status}; the message was not delivered ` + + '— retry after it settles to start the next activation', + 'NOT_DELIVERED', + ) + } + const run = activation.run + if (run === undefined) { + throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') + } + if (run.steer === undefined) { + throw new SubagentControlError( + `subagent "${childId}" provider does not accept live delivery; the message was not delivered`, + 'NOT_DELIVERED', + ) + } + try { + run.steer(message) + } catch (error: unknown) { + // Strict steering lost the race with turn settlement. Deliberately no + // cold-resume fallback here: that would attach the message to a turn the + // caller did not observe. + throw new SubagentControlError( + `subagent "${childId}" stopped before delivery; the message was not delivered`, + 'NOT_DELIVERED', + { cause: error }, + ) + } + return taskId + } + + /** + * Cold-resume a persisted child into a fresh Task-backed activation. The + * Task owns its `AbortController` before descriptor lookup: the load, + * direct-parent authorization, and descriptor fold run inside the + * activation, with cancellation rechecked after the un-signalled + * persistence await so an early `task_kill` prevents any later child work. + */ + private resumeActivation(parent: Agent, childId: SessionId, message: ContentBlock[]): TaskId { + const persistence = this.requirePersistence() + return this.startActivation(childId, resumeLabel(message), parent, async (signal) => { + let loaded: Awaited> + try { + loaded = await persistence.load(childId) + } catch (error: unknown) { + throw new SubagentControlError( + `subagent "${childId}" is unavailable`, + 'NOT_RESUMABLE', + { cause: error }, + ) + } + // The persistence seam takes no signal; recheck before any child work. + if (signal.aborted) throw new SubagentControlError('subagent resume was cancelled during lookup', 'CANCELLED') + // Authorize the persisted header before folding: only the direct parent + // recorded at creation may continue this child. + if (loaded.meta.parentSession !== parent.id) { + throw new SubagentControlError( + `subagent "${childId}" belongs to another parent session`, + 'UNAUTHORIZED', + ) + } + // Fold only the child's own suffix: a fork seed replays the parent's + // log, which may carry an ANCESTOR's descriptor when the parent is + // itself a continuable child. + const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0)) + if (descriptor === undefined) { + throw new SubagentControlError( + `subagent "${childId}" has no supported continuation descriptor`, + 'NOT_RESUMABLE', + ) + } + return this.ctx.subagents.resume(descriptor.provider, { + sessionId: childId, + prompt: message, + parent, + signal, + descriptor, + }) + }) + } + + /** + * Install the activation association, register its Task, and bind the two + * removal conditions. The association is installed before any persistence + * or provider await — the producer body runs synchronously up to its first + * await — and removed only after run disposal (the producer settled) and + * Task terminal publication. This synchronous install admits one activation + * per child in this process; a competing untracked publication still loses + * at the Agent registry collision boundary inside the provider. + */ + private startActivation( + childId: SessionId, + label: string, + owner: Agent, + begin: (signal: AbortSignal) => Promise, + ): TaskId { + const activation: ActiveActivation = { + taskId: undefined, + run: undefined, + terminal: Promise.withResolvers(), + } + this.activations.set(childId, activation) + let taskId: TaskId + try { + taskId = this.ctx.tasks.start({ + kind: 'subagent', + label, + owner, + run: (): TaskHooks => { + const controller = new AbortController() + const done = (async (): Promise => { + try { + const run = await begin(controller.signal) + activation.run = run + return await settleRun(run) + } catch (error: unknown) { + // A pre-publication abort rejects only after the provider's + // creation transaction rolled back to quiescence, so recording + // `killed` here honors the settlement-after-rollback contract. + return controller.signal.aborted + ? { status: 'killed' } + : { status: 'failed', detail: String(error) } + } + })() + void Promise.allSettled([done, activation.terminal.promise]).then(() => { + /* v8 ignore else -- service teardown clears the map while a producer is still settling. */ + if (this.activations.get(childId) === activation) this.activations.delete(childId) + }) + return { + cancel: (reason?: string) => { + // Cancellation targets the whole activation: every message that + // joined this turn shares the `killed` outcome. + controller.abort(reason ?? 'subagent activation killed') + }, + done, + // No readOutput: the child session owns intermediate detail. + } + }, + }) + } catch (error: unknown) { + // Task preflight failed; nothing started, so the install rolls back. + this.activations.delete(childId) + throw error + } + // Same synchronous frame as the install: an observer that can run at all + // runs after this assignment. + activation.taskId = taskId + return taskId + } + + /** Resolve the persistence service continuable children require, or fail loud. */ + private requirePersistence(): SessionPersistence { + const persistence = this.ctx.get('sessionPersistence') + if (persistence === undefined) { + throw new SubagentControlError( + 'continuable subagents require session persistence (load a dsh-session-persistence backend)', + 'PERSISTENCE_UNAVAILABLE', + ) + } + return persistence + } +} + +/** Derive a resumed activation's Task label from its message. */ +function resumeLabel(message: ContentBlock[]): string { + const text = finalText(message).trim().replace(/\s+/g, ' ') + if (text.length === 0) return 'subagent follow-up' + return text.length > 80 ? `${text.slice(0, 79)}…` : text +} + +export default SubagentControlService diff --git a/packages/subagent/subagent-control/src/invariant.ts b/packages/subagent/subagent-control/src/invariant.ts new file mode 100644 index 0000000000..ce40f360ca --- /dev/null +++ b/packages/subagent/subagent-control/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-control`. + * @module @deepseek-ai/dsh-subagent-control/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-control' + +/** Cordis companion plugin name. */ +export const name = 'subagent-control-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the activation association is deliberately private + * process-local routing state with no event stream of its own; the run + * lifecycle pair it participates in is checked by `@deepseek-ai/dsh-subagent`, + * and Task lifecycle relations belong to `@deepseek-ai/dsh-tasks`. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts new file mode 100644 index 0000000000..c76cc5ad41 --- /dev/null +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -0,0 +1,539 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' +import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' +import { TaskId } from '@deepseek-ai/dsh-tasks' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts' + +type Script = ConstructorParameters[0] + +/** One scripted response that may wait on a caller-released gate before streaming. */ +interface GatedEntry { + chunks: StreamChunk[] + gate?: Promise +} + +/** Adapter whose entries can hold a model call open until the test releases it. */ +class GatedAdapter extends LlmAdapter { + constructor(private script: GatedEntry[]) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + const entry = this.script.shift() + if (!entry) throw new Error('GatedAdapter: script exhausted') + if (entry.gate) await entry.gate + for (const chunk of entry.chunks) { + if (options.signal?.aborted) throw new Error('aborted') + yield chunk + } + } +} + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +/** Boot the full continuable stack: loop, persistence, providers, tasks, control. */ +async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + if (options.persistence !== false) { + const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-control-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + } + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + await ctx.plugin(SubagentFork, { providerName: 'fork' }) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks, {}) + await ctx.plugin(SubagentControlService) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent } +} + +async function setup(script: Script, options: { persistence?: boolean } = {}) { + const adapter = new MockAdapter(script) + const { ctx, parent } = await setupWith(adapter, options) + return { ctx, parent, adapter } +} + +function startSpec(parent: Agent, provider = 'spawn') { + return { + provider, + label: 'delegated work', + request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent }, + } +} + +async function waitTerminal(ctx: Context, taskId: TaskId, parent: Agent) { + return ctx.tasks.wait(taskId, 5_000, parent) +} + +function message(text: string) { + return [{ type: 'text' as const, text }] +} + +describe('SubagentControlService.startContinuable', () => { + it('returns both identities immediately; the Task settles with the child result after disposal', async () => { + const { ctx, parent } = await setup([textResponse('first answer')]) + const started = ctx.subagentControl.startContinuable(startSpec(parent)) + expect(started.childId).toMatch(/[0-9a-f-]{36}/) + expect(started.taskId).toBe('subagent-1') + + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('completed') + expect(ctx.tasks.read(started.taskId, parent).text).toBe('first answer') + // Disposal ordering: the terminal Task leaves no live child Agent. + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) + + it('publishes the control-allocated child id and appends the turn-enclosed descriptor', async () => { + const { ctx, parent } = await setup([textResponse('answer')]) + const seen: SessionEvent[] = [] + ctx.on('session/event', (session, event) => { + if (session.id !== SessionId('parent')) seen.push(event) + }) + const started = ctx.subagentControl.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + + const descriptorIndex = seen.findIndex(event => event.type === 'subagent/descriptor') + const turnStartIndex = seen.findIndex(event => event.type === 'turn/start') + const firstAssistant = seen.findIndex(event => event.type === 'assistant/message') + expect(descriptorIndex).toBeGreaterThan(turnStartIndex) + expect(descriptorIndex).toBeLessThan(firstAssistant) + const descriptor = seen[descriptorIndex] as SessionEvent<'subagent/descriptor'> + expect(descriptor.data).toEqual({ + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + agentProvider: 'mock', + agentModel: 'mock', + }) + // Model-hidden: the descriptor never carries surface metadata. + expect('surfaceOp' in descriptor).toBe(false) + + // The durable log kept the exact control-allocated id. + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.meta.id).toBe(started.childId) + expect(loaded.meta.parentSession).toBe(SessionId('parent')) + expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) + }) + + it('rejects synchronously with no Task when persistence is not configured', async () => { + const { ctx, parent } = await setup([textResponse('unused')], { persistence: false }) + expect(() => ctx.subagentControl.startContinuable(startSpec(parent))) + .toThrow(/require session persistence/) + expect(ctx.tasks.list(parent)).toEqual([]) + }) + + it('rejects a non-JSON descriptor input synchronously with no Task', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + const spec = startSpec(parent) + expect(() => ctx.subagentControl.startContinuable({ + ...spec, + // A symbol survives the static ToolRestriction type only through this + // cast — exactly the durable-boundary input the snapshot rejects. + request: { ...spec.request, toolFilter: { deny: [Symbol('boom') as unknown as string] } }, + })).toThrow(/not losslessly JSON-serializable/) + expect(ctx.tasks.list(parent)).toEqual([]) + }) + + it('settles the Task as failed when provider startup fails after the ids were returned', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + const spec = { + provider: 'spawn', + label: 'broken delegation', + request: { + prompt: [{ type: 'text' as const, text: 'child task' }], + parent, + // The spawn provider enforces depth: parent depth 0 → child depth 1 > 0. + maxDepth: 0, + }, + } + const started = ctx.subagentControl.startContinuable(spec) + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('maxDepth') + // The unmaterialized child id is reported unavailable on later use. + const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('hello?')) + expect(followUp.route).toBe('started') + const failed = await waitTerminal(ctx, followUp.taskId, parent) + expect(failed.status).toBe('failed') + expect(failed.detail).toContain('unavailable') + }) + + it('task_kill during the run aborts, disposes, and settles killed after quiescence', async () => { + const { ctx, parent } = await setup(['hang']) + const started = ctx.subagentControl.startContinuable(startSpec(parent)) + // Let the child publish and begin its turn. + await new Promise(resolve => setTimeout(resolve, 30)) + expect(ctx.agents.get(started.childId)).toBeDefined() + expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested') + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('killed') + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) +}) + +describe('SubagentControlService.sendMessage', () => { + it('steers a running activation into the existing Task without creating a second Task', async () => { + // Hold the child's first model call open so the child is observably + // running when the message arrives; the steered content then drives a + // second step in the SAME turn. + let releaseFirst!: () => void + const gate = new Promise((resolve) => { releaseFirst = resolve }) + const { ctx, parent } = await setupWith(new GatedAdapter([ + { chunks: textResponse('first step answer'), gate }, + { chunks: textResponse('steered turn answer') }, + ])) + + const started = ctx.subagentControl.startContinuable(startSpec(parent)) + // Wait for the child agent to publish and enter running. + await new Promise((resolve) => { + const timer = setInterval(() => { + if (ctx.agents.get(started.childId)?.status === 'running') { + clearInterval(timer) + resolve() + } + }, 5) + }) + + const delivered = ctx.subagentControl.sendMessage(parent, started.childId, message('also consider Y')) + expect(delivered).toEqual({ route: 'steered', taskId: started.taskId }) + releaseFirst() + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('completed') + // Exactly one Task exists: steering created none. + expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId]) + // The steered content joined the SAME child turn and drove another step. + const output = ctx.tasks.read(started.taskId, parent) + expect(output.text).toBe('steered turn answer') + }) + + it('cold-resumes a settled child into a fresh Task and reports `started`', async () => { + const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) + const started = ctx.subagentControl.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + expect(ctx.agents.get(started.childId)).toBeUndefined() + + const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('and then?')) + expect(followUp.route).toBe('started') + expect(followUp.taskId).not.toBe(started.taskId) + const snapshot = await waitTerminal(ctx, followUp.taskId, parent) + expect(snapshot.status).toBe('completed') + expect(ctx.tasks.read(followUp.taskId, parent).text).toBe('second answer') + // Fresh activation disposed again: durable child, no live Agent. + expect(ctx.agents.get(started.childId)).toBeUndefined() + + // The durable transcript accumulated BOTH activations' turns. + const loaded = await ctx.sessionPersistence.load(started.childId) + const userMessages = loaded.events.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message') + expect(userMessages.map(event => (event.data.content[0] as { text: string }).text)) + .toEqual(['child task', 'and then?']) + }) + + it('reconstructs the declared composition on cold resume', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) + const spec = { + provider: 'spawn', + label: 'scoped delegation', + request: { + prompt: [{ type: 'text' as const, text: 'child task' }], + parent, + persona: 'You are the resumable child.', + toolFilter: { deny: [] as string[] }, + }, + } + const started = ctx.subagentControl.startContinuable(spec) + await waitTerminal(ctx, started.taskId, parent) + + const loaded = await ctx.sessionPersistence.load(started.childId) + const descriptor = loaded.events.find((event): event is SessionEvent<'subagent/descriptor'> => event.type === 'subagent/descriptor') + expect(descriptor?.data.persona).toBe('You are the resumable child.') + expect(descriptor?.data.toolFilter).toEqual({ deny: [] }) + + const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('continue')) + const snapshot = await waitTerminal(ctx, followUp.taskId, parent) + expect(snapshot.status).toBe('completed') + // The resumed child's system prompt carried the persona back. + const resumed = await ctx.sessionPersistence.load(started.childId) + const headers = resumed.events.filter((event): event is SessionEvent<'request/header'> => event.type === 'request/header') + expect(headers.at(-1)?.data.header.system).toContain('You are the resumable child.') + }) + + it('fork children resume from their own transcript without re-forking parent history', async () => { + const { ctx, parent } = await setup([ + textResponse('parent turn one'), + textResponse('fork first answer'), + textResponse('parent turn two'), + textResponse('fork second answer'), + ]) + parent.followup(createUserMessage({ content: message('parent question one'), source: { kind: 'user' } })) + await parent.whenIdle() + + const started = ctx.subagentControl.startContinuable(startSpec(parent, 'fork')) + await waitTerminal(ctx, started.taskId, parent) + const firstLoad = await ctx.sessionPersistence.load(started.childId) + const seedLength = firstLoad.meta.seedLength ?? 0 + expect(seedLength).toBeGreaterThan(0) + + // The parent gains NEW history the resume must not re-fork. + parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } })) + await parent.whenIdle() + + const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('follow up')) + await waitTerminal(ctx, followUp.taskId, parent) + const resumed = await ctx.sessionPersistence.load(started.childId) + // The persisted seed boundary is unchanged and parent turn two is absent. + expect(resumed.meta.seedLength).toBe(seedLength) + const texts = resumed.events + .filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message') + .map(event => (event.data.content[0] as { text: string }).text) + expect(texts).toContain('parent question one') + expect(texts).not.toContain('parent question two') + }) + + it('a resumed child cannot regain a top-level delegation budget (header floor)', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) + const started = ctx.subagentControl.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('go on')) + + const childAgents: Agent[] = [] + const stop = ctx.on('agent/created', (agent: Agent) => { + if (agent.id === started.childId) childAgents.push(agent) + }) + await waitTerminal(ctx, followUp.taskId, parent) + stop() + // The resumed runtime options carry no depth, so the header keeps the floor. + const resumedChild = childAgents.at(-1) + expect(resumedChild).toBeDefined() + expect(resumedChild!.session.header.delegationDepth).toBe(1) + }) + + it('rejects a foreign child id: the started Task fails with UNAUTHORIZED and delivers nothing', async () => { + const { ctx, parent } = await setup([textResponse('other parent answer'), textResponse('unused')]) + const otherParent = ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' }) + const started = ctx.subagentControl.startContinuable(startSpec(otherParent)) + await waitTerminal(ctx, started.taskId, otherParent) + + const attempt = ctx.subagentControl.sendMessage(parent, started.childId, message('mine now')) + expect(attempt.route).toBe('started') + const snapshot = await waitTerminal(ctx, attempt.taskId, parent) + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('another parent session') + }) + + it('rejects a persisted child with no descriptor as not resumable', async () => { + const { ctx, parent } = await setup([textResponse('plain child')]) + // A plain (non-continuable) child session persisted under this parent. + const handle = await ctx.agents.create({ + sessionId: SessionId('plain-child'), + meta: { parentSession: parent.id, delegationDepth: 1 }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + handle.agent.followup(createUserMessage({ content: message('do something'), source: { kind: 'user' } })) + await handle.agent.whenIdle() + await handle.dispose() + + const attempt = ctx.subagentControl.sendMessage(parent, SessionId('plain-child'), message('continue?')) + const snapshot = await waitTerminal(ctx, attempt.taskId, parent) + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('continuation descriptor') + }) + + it('rejects delivery to a live agent outside control-service ownership', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + // A live child created around the control service. + const handle = await ctx.agents.create({ + sessionId: SessionId('rogue-child'), + meta: { parentSession: parent.id }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello'))) + .toThrow(SubagentControlError) + expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello'))) + .toThrow(/outside control-service ownership.*not delivered/) + await handle.dispose() + }) + + it('does not fall through to cold resume when strict steering loses the settlement race', async () => { + // Deterministic race: hold run disposal open so the association still + // names a run whose child turn has already ended. + const { ctx, parent } = await setup([textResponse('quick answer'), textResponse('unused')]) + let releaseDispose!: () => void + const disposeGate = new Promise((resolve) => { releaseDispose = resolve }) + const realStart = ctx.subagents.start.bind(ctx.subagents) + ctx.subagents.start = async (name, request) => { + const run = await realStart(name, request) + const realDispose = run.dispose.bind(run) + return { + ...run, + ...run.steer !== undefined ? { steer: run.steer.bind(run) } : {}, + dispose: async () => { + await disposeGate + return realDispose() + }, + } + } + + const started = ctx.subagentControl.startContinuable(startSpec(parent)) + // Wait for the child to finish its turn while the run remains undisposed + // and the association therefore still holds. + await new Promise((resolve) => { + const timer = setInterval(() => { + const child = ctx.agents.get(started.childId) + if (child !== undefined && child.status === 'idle' + && child.session.events.some(event => event.type === 'turn/end')) { + clearInterval(timer) + resolve() + } + }, 5) + }) + + // Strict steering finds the settled child, fails loud, and does NOT start + // a cold resume within this call. + expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('too late?'))) + .toThrow(/not delivered/) + expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId]) + releaseDispose() + await waitTerminal(ctx, started.taskId, parent) + // AFTER the Task settles, retry legitimately starts the next activation. + const retry = ctx.subagentControl.sendMessage(parent, started.childId, message('retry')) + expect(retry.route).toBe('started') + await waitTerminal(ctx, retry.taskId, parent) + }) + + it('each follow-up Task result is fenced to the parent session', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) + const started = ctx.subagentControl.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('more')) + const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' }) + expect(() => ctx.tasks.get(followUp.taskId, other)).toThrow(/belongs to another session/) + }) + + it('kills a cold-resume activation during descriptor lookup without starting child work', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('never used')]) + const started = ctx.subagentControl.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + + // Make the persistence load hang until the kill lands. + const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) + let releaseLoad!: () => void + const gate = new Promise((resolve) => { releaseLoad = resolve }) + ctx.sessionPersistence.load = async (id) => { + await gate + return realLoad(id) + } + + const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('follow up')) + expect(ctx.tasks.kill(followUp.taskId, parent)).toBe('requested') + releaseLoad() + const snapshot = await waitTerminal(ctx, followUp.taskId, parent) + expect(snapshot.status).toBe('killed') + // Cancellation during lookup prevented any child publication. + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) + + it('admits one process-local activation per child: a second send during resume load steers or fails, never duplicates', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed answer')]) + const started = ctx.subagentControl.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + + const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) + let releaseLoad!: () => void + const gate = new Promise((resolve) => { releaseLoad = resolve }) + ctx.sessionPersistence.load = async (id) => { + await gate + return realLoad(id) + } + + const first = ctx.subagentControl.sendMessage(parent, started.childId, message('first follow-up')) + expect(first.route).toBe('started') + // The association is installed synchronously, so the competing caller + // observes the pending activation instead of starting a duplicate resume. + expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('second follow-up'))) + .toThrow(/not delivered/) + releaseLoad() + const snapshot = await waitTerminal(ctx, first.taskId, parent) + expect(snapshot.status).toBe('completed') + // Exactly one follow-up Task was created. + expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId, first.taskId]) + }) +}) + +describe('outcome mapping helpers', () => { + it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => { + const output = [{ type: 'text' as const, text: 'partial' }] + expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' }) + expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' }) + expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' }) + expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' }) + expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' }) + // Merge-extensible: an unknown reason is failed-with-detail, never success. + expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' }) + }) + + it('settleRun disposes the run before reporting, on both result paths', async () => { + const order: string[] = [] + const completed = await settleRun({ + id: SessionId('child-1'), + localAgent: undefined, + result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }), + dispose() { order.push('dispose'); return Promise.resolve() }, + }) + order.push('reported') + expect(completed).toEqual({ status: 'completed', output: 'ok' }) + expect(order).toEqual(['dispose', 'reported']) + + // An infrastructure rejection still disposes and reports failed. + let disposed = false + const failed = await settleRun({ + id: SessionId('child-2'), + localAgent: undefined, + result: Promise.reject(new Error('transport gone')), + dispose() { disposed = true; return Promise.resolve() }, + }) + expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' }) + expect(disposed).toBe(true) + + const disposeFailed = await settleRun({ + id: SessionId('child-3'), + localAgent: undefined, + result: Promise.resolve({ output: [], stopReason: 'completed' }), + dispose: () => Promise.reject(new Error('reap failed')), + }) + expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' }) + + const bothFailed = await settleRun({ + id: SessionId('child-4'), + localAgent: undefined, + result: Promise.reject(new Error('result failed')), + dispose: () => Promise.reject(new Error('reap failed')), + }) + expect(bothFailed).toEqual({ + status: 'failed', + detail: 'Error: result failed; dispose failed: Error: reap failed', + }) + }) +}) diff --git a/packages/subagent/subagent-control/tsconfig.json b/packages/subagent/subagent-control/tsconfig.json new file mode 100644 index 0000000000..d41aacf4fb --- /dev/null +++ b/packages/subagent/subagent-control/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../subagent" + }, + { + "path": "../../tasks/tasks" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index b448dc309b..55475aee78 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -57,5 +57,4 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. - **The seed is a one-time snapshot** — the child sees the parent's completed turns as of the fork and nothing the parent logs afterwards; there is no live context sharing. diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index a96ce4f06e..37e2556d44 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -11,8 +11,8 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import type { SubagentCapabilities, SubagentProvider, SubagentResumeRequest, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-fork' // `tools` is deliberately NOT injected — same rationale as subagent-spawn: the @@ -67,6 +67,13 @@ class ForkProvider implements SubagentProvider { ...seed.length > 0 ? { seed } : {}, }) } + + resume(request: SubagentResumeRequest) { + // Cold resume loads the child's OWN persisted transcript, which already + // contains the completed-turn prefix captured at initial creation; it + // never forks the parent's newer history again. + return resumeInProcessRun(request) + } } export function apply(ctx: Context, config: Config): void { diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index b834818199..bd951115d2 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: 980bc18de088c41dfe2f57a5ff0882a60892fc9f -README.zh.md: 1ceb628371c3ae9cee6d8afa6bc1d95ba4cda8ae +README.md: 7587b6dfc44bef90756c9f2aba96d54872935fee +README.zh.md: 751e745c6c7a64831debd2df58ed8c3d7861f84d diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 980bc18de0..7587b6dfc4 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation, optional child customization, result reading, cancellation, and disposal—has one implementation here. +This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation and cold resume, optional child customization, result reading, cancellation, strict steering, and disposal—has one implementation here. ## Start contract @@ -11,21 +11,27 @@ This package is the shared run driver for the two in-process providers. Spawn pa The driver follows this sequence: 1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header. -2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. -3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. +2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id. +3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/pre-step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later between-turn records. +5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns. The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). +## Cold resume + +`resumeInProcessRun(request): Promise` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, abort handoff, and disposal follow the same contract as start. + ## Cancellation and ownership The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child. After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed. +Runs expose the strict `steer` capability: a synchronous `AgentStatus.running` check and `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. + ## Spawn and fork inputs `InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output. @@ -110,5 +116,4 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. - **Structured capture accepts the `defineTool` schema subset only** — unsupported JSON Schema constructs fail before the child is created; a provider needing a broader schema vocabulary requires a different runtime. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 1ceb628371..751e745c6c 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建、可选的子 agent 定制、结果读取、取消和 dispose(资源释放),都在此共用同一套实现。 +本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、严格 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。 ## 启动契约 @@ -11,21 +11,27 @@ 驱动器按以下顺序运行: 1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。 -2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 -3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 +2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 +3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/pre-step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续轮次间记录。 +5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的零步骤轮次。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 +## 冷恢复 + +`resumeInProcessRun(request): Promise` 会在当前父级作用域下重建持久化的可继续子 agent:`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript(文本记录;fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、中止交接和 dispose 遵循与启动相同的契约。 + ## 取消与所有权 必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。 兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。 +运行公开严格的 `steer` 功能:同步的 `AgentStatus.running` 检查与 `Agent.steer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的轮次,要么抛错。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback;否则会在运行结果读取后启动一个未被跟踪的轮次。 + ## Spawn 与 fork 输入 `InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`。spawn 省略该值。fork 提供平衡的已完成轮次前缀,并记录其长度,确保结果读取器不会把作为初始内容的父 agent 消息误认为子 agent 输出。 @@ -110,5 +116,4 @@ When you have your final answer, you MUST report it by calling the `structured_o ## 已知限制与延期工作 -- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。 - **结构化捕获只接受 `defineTool` schema 子集**:不支持的 JSON Schema 构造会在子 agent 创建前失败;需要更广 schema 词汇的提供方必须采用不同的运行时。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 8d659ae73b..695283a027 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -9,11 +9,18 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent' -import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' +import type { + SubagentDescriptorData, + SubagentResult, + SubagentResumeRequest, + SubagentRun, + SubagentStartRequest, + SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' // Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve // to the policy services when composed — the driver consumes both // opportunistically (the documented `ctx.get` pattern), never as a hard dep. @@ -65,10 +72,27 @@ function prePublicationAbort(): Error { return new Error('subagent request was aborted before child publication') } +/** + * Register the one-shot child-scoped contribution that appends the durable + * `subagent/descriptor` event. `agent/step` is the first serial seam + * inside the child's initial turn, so the append lands after `turn/start` and + * before the first request, and reaches persistence with that turn's flush. + */ +function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { + let appended = false + childCtx.on('agent/step', (agent) => { + if (appended) return + appended = true + agent.session.append('subagent/descriptor', descriptor) + }) +} + /** * Establish and drive one in-process child. Fulfillment means the agent is * already published in the registry; rejection means the agent factory's * creation transaction and any partially-created child have reached quiescence. + * A `request.continuation` publishes exactly its stable child id and appends + * its descriptor inside the child's initial turn. * @param request - the trusted typed start request, including its required signal. * @param options - the optional fork seed. * @returns a ready holder-owned run. @@ -88,7 +112,9 @@ export async function startInProcessRun( throw new SubagentDepthError(childDepth, request.maxDepth) } - const childId = SessionId(randomUUID()) + // A continuable delegation names the durable conversation up front; the + // provider publishes exactly that id instead of allocating one internally. + const childId = request.continuation?.sessionId ?? SessionId(randomUUID()) const seedLength = options.seed?.length ?? 0 const parentHeader = parent.session.header const parentProvider = parent.options.provider @@ -123,9 +149,11 @@ export async function startInProcessRun( if (request.outputSchema !== undefined) { structured = attachStructuredRuntime(childCtx, request.outputSchema) } + if (request.continuation !== undefined) { + attachDescriptorAppend(childCtx, request.continuation.descriptor) + } } - const flags = { cancelled: false } const handle = await parent.ctx.agents.create({ sessionId: childId, meta: { @@ -140,36 +168,84 @@ export async function startInProcessRun( signal: request.signal, setup, }) + return driveTurn(handle, request.signal, request.prompt, childId, seedLength, structured) +} + +/** + * Reconstruct a persisted continuable child under the live parent's scope and + * drive one follow-up turn. The resumed session's own transcript is the seed + * (loaded through the parent's persistence-backed registry `resume`), so a + * fork child never re-forks current parent history; the persisted header + * remains authoritative for lineage and the delegation-depth floor. + * @param request - the fully resolved resume request from the low-level service. + * @returns a fresh ready holder-owned run for this activation. + */ +export async function resumeInProcessRun(request: SubagentResumeRequest): Promise { + if (request.signal.aborted) throw prePublicationAbort() + const descriptor = request.descriptor + const agentOptions: AgentOptions = { + ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, + ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, + } + const setup = (childCtx: Context): void => { + if (descriptor.persona !== undefined) { + childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: descriptor.persona }) + } + if (descriptor.toolFilter !== undefined) childCtx.tools.restrict(descriptor.toolFilter) + } + + const handle = await request.parent.ctx.agents.resume({ + resumeSessionId: request.sessionId, + agentOptions, + signal: request.signal, + setup, + }) + // The result boundary is this activation's own work: everything already in + // the resumed transcript belongs to earlier turns. + const resumePoint = handle.agent.session.events.length + return driveTurn(handle, request.signal, request.prompt, request.sessionId, resumePoint) +} + +/** + * Drive one activation turn on a published child and wrap it as a run. The + * caller has already created or resumed the agent; this owns the + * signal-handoff race, the live abort listener, result collection past + * `boundary`, strict steering, and disposal. + */ +function driveTurn( + handle: AgentHandle, + signal: AbortSignal, + prompt: ContentBlock[], + childId: SessionId, + boundary: number, + structured?: StructuredAttachment, +): SubagentRun | Promise { const child = handle.agent // Agent creation detaches its creation-only abort listener before returning. // Close the narrow handoff race before installing the live-run listener. - // Static analysis does not model the abort that may land between the - // factory's listener detachment and this continuation. - // oxlint-disable-next-line typescript/no-unnecessary-condition - if (request.signal.aborted) { - flags.cancelled = true - await handle.dispose() - throw prePublicationAbort() + if (signal.aborted) { + return handle.dispose().then(() => { throw prePublicationAbort() }) } + const flags = { cancelled: false } const onAbort = (): void => { flags.cancelled = true child.cancel({ kind: 'parent' }) } - request.signal.addEventListener('abort', onAbort, { once: true }) + signal.addEventListener('abort', onAbort, { once: true }) const result: Promise = (async () => { try { - child.followup(createUserMessage({ content: request.prompt, source: { kind: 'user' } })) + child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } })) await child.whenIdle() return readResult( child, - seedLength, + boundary, flags.cancelled, structured ? { captured: structured.captured() } : undefined, ) } finally { - request.signal.removeEventListener('abort', onAbort) + signal.removeEventListener('abort', onAbort) } })() @@ -178,21 +254,31 @@ export async function startInProcessRun( localAgent: child, result, dispose(): Promise { - request.signal.removeEventListener('abort', onAbort) + signal.removeEventListener('abort', onAbort) flags.cancelled = true return handle.dispose() }, + steer(content: ContentBlock[]): void { + // Strict live delivery: the synchronous running check and Agent.steer() + // call share one frame, so delivery joins the observed turn or throws. + // Agent.steer()'s own idle fallback would instead QUEUE the message and + // start a new, untracked turn after this run's result was read. + if (child.status !== 'running') { + throw new Error(`subagent child "${childId}" is not running; the message was not delivered`) + } + child.steer(createUserMessage({ content, source: { kind: 'user' } })) + }, } } -/** Read one settled child's result from events after its optional fork seed. */ +/** Read one settled child's result from events after its activation boundary. */ function readResult( child: Agent, - seedLength: number, + boundary: number, cancelled: boolean, structured?: { captured?: { value: unknown } | undefined }, ): SubagentResult { - const own = child.session.events.slice(seedLength) + const own = child.session.events.slice(boundary) const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message') const lastEnd = findLastMessageTurnEnd(own) const output: ContentBlock[] = lastMessage?.data.message.content ?? [] diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 868f829edb..811f19e6e6 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -52,5 +52,4 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. - **Fresh means no parent transcript** — the child inherits cwd, lineage, model, and explicitly configured persona/tool restrictions, but none of the parent's conversation; use the fork provider when completed-turn context is required. diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index c006272cd5..22594fef2e 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -8,8 +8,8 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import type { SubagentCapabilities, SubagentProvider, SubagentResumeRequest, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' // `tools` is deliberately not injected: the child factory already provides it during setup, @@ -46,6 +46,12 @@ class SpawnProvider implements SubagentProvider { // request carries an outputSchema), and maps the result. return startInProcessRun(request, {}) } + + resume(request: SubagentResumeRequest) { + // Cold resume reconstructs the persisted child from its own transcript + // under the live parent scope; the shared driver drives the follow-up turn. + return resumeInProcessRun(request) + } } export function apply(ctx: Context, config: Config): void { diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 2d6338701d..1e43b074b4 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -235,12 +235,19 @@ describe('dsh-subagent-spawn', () => { expect(result.stopReason).toBe('aborted') }) - it('does not expose the optional runtime methods (sendMessage/resume) in this cut', async () => { + it('exposes strict steer (no run-level resume): a settled child throws instead of queueing', async () => { const { ctx, parent } = await setup([textResponse('x')]) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) - expect('sendMessage' in run).toBe(false) + // A run represents one disposable activation: cold resume is a provider + // method, never a run method. expect('resume' in run).toBe(false) + expect(typeof run.steer).toBe('function') await run.result + // Strict live-only contract: after the child settles, delivery fails loud + // rather than falling back to Agent.steer()'s idle queue (which would + // start an untracked turn). + expect(() => { run.steer!([{ type: 'text', text: 'late' }]) }) + .toThrow(/not running; the message was not delivered/) await run.dispose() }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 3d5d5e7498..c7bf9af45a 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -10,17 +10,19 @@ The family separates the stable interface from implementations and model-facing | Package | Role | |---|---| -| `@deepseek-ai/dsh-subagent` | Provider registry, request/result types, and lifecycle events. | -| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child. | -| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns. | -| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child. | -| `@deepseek-ai/dsh-tool-subagent` | Model-facing tool over one configured provider. | +| `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, and lifecycle events. | +| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child, with cold resume. | +| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns, with cold resume. | +| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | +| `@deepseek-ai/dsh-subagent-control` | Continuable-child orchestration: durable ids, descriptors, Task-backed activation. | +| `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. | +| `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. | Multiple providers may coexist under different names. This lets a deployment expose, for example, a cheap in-process child and an isolated ACP child without changing the service contract. ## Service API -`SubagentService` has four main operations: +`SubagentService` has five main operations: | Member | Meaning | |---|---| @@ -28,8 +30,9 @@ Multiple providers may coexist under different names. This lets a deployment exp | `getProvider(name)` | Return the provider, or `undefined` when absent. | | `list()` | Return provider names in insertion order. | | `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. | +| `resume(name, request)` | Capability-checked dispatch to `provider.resume?()` with the same run lifecycle observation as `start`. The caller (the control service) has already loaded the child, folded its descriptor, and authorized the parent; this seam stays collection-, Task-, and persistence-agnostic. | -`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. +`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, set a child persona, or carry a resolved `continuation` (the control-allocated stable child id plus its durable descriptor), which requires the provider's `resume` capability. Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries. @@ -42,23 +45,27 @@ Start-time features are advertised in `provider.capabilities` because the servic - `toolFilter` — apply the requested child tool restriction. - `persona` — apply a per-child persona. +Runtime features are optional methods whose presence is the capability check: `SubagentRun.steer?` delivers strictly to the actively running child turn (it throws rather than queueing when the child is not running), and `SubagentProvider.resume?` reconstructs a persisted continuable child. A run represents one disposable activation, so it deliberately has no cold-resume operation — a disposed run cannot be reconstructed after restart. + +## The durable descriptor + +The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before any Task exists, and `foldSubagentDescriptor()` recovers it from a loaded child log. The payload records the provider name, resolved child `agentOptions.provider`/`model`, and optional `persona`/`toolFilter` — explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. It omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. + ## Delegation depth The seam owns the depth vocabulary shared by implementations and consumers: the `AgentOptions.subagentDepth` declaration, `assertSubagentMaxDepth`, and `delegationDepthOf(agent)`. The persisted `SessionHeader.delegationDepth` is authoritative and monotone — runtime options may deepen the count but never lower it, so a resumed child cannot be re-counted as top-level. -Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a live child, while `resume?` asynchronously creates a continuation run. Method presence is the capability check. - `inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority. ## Ownership and lifecycle -`provider.start(request): Promise` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. +`provider.start(request): Promise` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. `provider.resume?(request)` shares the same contract for a resumed activation. `SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. -A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. +A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the control-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. -The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names. +The service emits `subagent/start` only after `start()` or `resume()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names. Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. @@ -66,17 +73,17 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. Background delegation does not change this seam; the consumer registers startup and the eventual run with the generic `ctx.tasks` runtime, then collection and cancellation use the shared task tools. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. +The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. Background delegation does not change this seam; `@deepseek-ai/dsh-subagent-control` registers each activation with the generic `ctx.tasks` runtime, then collection and cancellation use the shared task tools. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. ## Model Experience -Indirectly, through `dsh-tool-subagent`, which renders provider-specific schemas and foreground or generic-background results while child working context remains child-only. +Indirectly, through `dsh-tool-subagent` and `dsh-tool-subagent-control`, which render provider-specific schemas and foreground, background, or follow-up results while child working context remains child-only. #### KV Cache effect -No direct invalidation; the named consumer owns any request-prefix changes. +No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **Runtime steering and continuation are seam-only capabilities** — `sendMessage` and `resume` have no model-facing consumer in the current tool. +- **ACP children remain one-shot** — `AcpProvider.resume` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the provider method's presence. - **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface waits for a concrete consumer. diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts new file mode 100644 index 0000000000..c837a696b8 --- /dev/null +++ b/packages/subagent/subagent/src/descriptor.ts @@ -0,0 +1,116 @@ +/** + * The durable continuable-child descriptor: the versioned, model-hidden + * `subagent/descriptor` session event that records a child's declared + * composition so a known child id can be cold-resumed after its run — and its + * process — are gone. Providers append it turn-enclosed in the child's initial + * turn; the control service folds it back on resume. + * + * The descriptor deliberately snapshots explicit fields rather than the + * merge-extensible `AgentOptions` object: an unrelated extension value cannot + * make continuation fail merely because it is not JSON, and later composition + * inputs require a deliberate {@link SUBAGENT_DESCRIPTOR_VERSION} change. It + * omits `subagentDepth` — cold resume trusts the persisted header's + * `delegationDepth` as the monotone floor — and `outputSchema`, which belongs + * to one activation's result contract rather than durable child composition. + * + * @module @deepseek-ai/dsh-subagent/descriptor + */ + +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { ToolRestriction } from '@deepseek-ai/dsh-tools' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * Durable declared composition of a continuable subagent child, appended + * once by the establishing provider inside the child's initial turn, + * before its first request. Log-only: it carries no `surfaceOp`, never + * enters model history, and the append-only log retains it when + * compaction replaces surface history. + */ + 'subagent/descriptor': SubagentDescriptorData + } +} + +/** + * The current descriptor format version, stamped into every appended + * `subagent/descriptor` event and required verbatim by {@link foldSubagentDescriptor}. + * Supporting another composition input is a deliberate version change, never + * an implicit extra field. + */ +export const SUBAGENT_DESCRIPTOR_VERSION = 1 + +/** The `subagent/descriptor` event payload — a continuable child's declared composition. */ +export interface SubagentDescriptorData { + /** Descriptor format version ({@link SUBAGENT_DESCRIPTOR_VERSION}). */ + readonly version: number + /** The `ctx.subagents` provider name that established the child. */ + readonly provider: string + /** Resolved child `agentOptions.provider`, when one was declared. */ + readonly agentProvider?: string + /** Resolved child `agentOptions.model`, when one was declared. */ + readonly agentModel?: string + /** Per-child persona that shadows the deployment persona on resume. */ + readonly persona?: string + /** Child tool scoping reapplied on resume. */ + readonly toolFilter?: ToolRestriction +} + +/** Inputs {@link snapshotSubagentDescriptor} validates and detaches. */ +export interface SubagentDescriptorInput { + /** The `ctx.subagents` provider name that will establish the child. */ + readonly provider: string + /** Requested child `agentOptions.provider`. */ + readonly agentProvider?: string + /** Requested child `agentOptions.model`. */ + readonly agentModel?: string + /** Requested per-child persona. */ + readonly persona?: string + /** Requested child tool scoping. */ + readonly toolFilter?: ToolRestriction +} + +/** + * Validate and detach descriptor inputs into the durable payload, before any + * Task or provider work begins — the same detached lossless-JSON boundary the + * session log itself enforces, applied early so a synchronous validation + * failure rejects the tool call without creating a Task. + * @param input - the caller-collected composition fields. + * @returns the versioned, detached descriptor payload. + * @throws when a field is not losslessly JSON-serializable. + */ +export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): SubagentDescriptorData { + const candidate: SubagentDescriptorData = { + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: input.provider, + ...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {}, + ...input.agentModel !== undefined ? { agentModel: input.agentModel } : {}, + ...input.persona !== undefined ? { persona: input.persona } : {}, + ...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {}, + } + const snapshot = snapshotJsonValue(candidate) + if (snapshot === undefined) { + throw new Error('subagent descriptor is not losslessly JSON-serializable') + } + return snapshot +} + +/** + * Fold a persisted child log to its supported descriptor. The first + * `subagent/descriptor` event is authoritative — the establishing provider + * appends exactly one, so a later same-type event cannot rewrite the declared + * composition. + * @param events - the loaded child session events. + * @returns the descriptor, or `undefined` when the log has none or its + * version is not {@link SUBAGENT_DESCRIPTOR_VERSION} (the child is not + * resumable by this runtime). + */ +export function foldSubagentDescriptor(events: readonly SessionEvent[]): SubagentDescriptorData | undefined { + const event = events.find( + (candidate): candidate is SessionEvent<'subagent/descriptor'> => candidate.type === 'subagent/descriptor', + ) + if (event === undefined) return undefined + if (event.data.version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined + return event.data +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 1267f276ab..4f9a013084 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -13,12 +13,13 @@ * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. * - * Scope: the seam stays collection-agnostic — a run is started and its - * `result` awaited, whether the consumer blocks on it (foreground) or - * registers it as a `ctx.tasks` background task (the generic runtime owns - * ids/polling/stop; this seam gains nothing task-shaped). Steering - * ({@link SubagentRun.sendMessage}) is part of the contract but intentionally - * unused. + * Scope: the seam stays collection-, Task-, and persistence-agnostic — a run + * is started or resumed and its `result` awaited, whether the consumer blocks + * on it (foreground) or registers it as a `ctx.tasks` background task (the + * generic runtime owns ids/polling/stop; this seam gains nothing task-shaped). + * Durable continuable-child ids, descriptor lookup, and Task association + * belong to `@deepseek-ai/dsh-subagent-control`; this service only validates + * and dispatches `start`/`resume` and observes run lifecycle. * * Same-process providers are trusted typed collaborators. Requests, provider * descriptors, results, and lifecycle payloads are borrowed immutable values; @@ -41,6 +42,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentResult, + SubagentResumeRequest, SubagentRun, SubagentStartRequest, } from './types.ts' @@ -50,13 +52,21 @@ export * from './out-of-process.ts' export { SubagentRunId } from './types.ts' export type { SubagentCapabilities, + SubagentContinuation, SubagentProvider, SubagentResult, + SubagentResumeRequest, SubagentRun, SubagentStartRequest, SubagentStopReason, SubagentStopReasonMap, } from './types.ts' +export { + foldSubagentDescriptor, + snapshotSubagentDescriptor, + SUBAGENT_DESCRIPTOR_VERSION, +} from './descriptor.ts' +export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts' declare module '@deepseek-ai/dsh-agent' { interface AgentOptions { @@ -237,16 +247,52 @@ export class SubagentService extends Service { * @returns the ready holder-owned run. */ async start(name: string, request: SubagentStartRequest): Promise { + const provider = this.expectProvider(name) + this.assertCapabilities(provider, request) + assertSubagentMaxDepth(request.maxDepth) + if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema) + if (request.continuation !== undefined && provider.resume === undefined) { + throw new SubagentError( + `subagent provider "${provider.name}" does not support continuable children (no resume capability)`, + 'UNSUPPORTED_CAPABILITY', + ) + } + + return this.observeRun(name, request.parent, await provider.start(request)) + } + + /** + * Resume a persisted continuable child through the named provider's + * `resume` capability, with the same run lifecycle observation as + * {@link start}. The caller (the control service) has already loaded the + * child, folded its descriptor, and authorized the parent; this method owns + * only capability-checked dispatch. + * @param name - the provider recorded in the child's descriptor. + * @param request - the fully resolved resume request. + * @returns the fresh holder-owned run for the resumed activation. + */ + async resume(name: string, request: SubagentResumeRequest): Promise { + const provider = this.expectProvider(name) + if (provider.resume === undefined) { + throw new SubagentError( + `subagent provider "${provider.name}" does not support resuming persisted children (no resume capability)`, + 'UNSUPPORTED_CAPABILITY', + ) + } + return this.observeRun(name, request.parent, await provider.resume(request)) + } + + /** Look up a provider for dispatch or fail loud. */ + private expectProvider(name: string): SubagentProvider { const provider = this.providers.get(name) if (provider === undefined) { throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER') } - this.assertCapabilities(provider, request) - assertSubagentMaxDepth(request.maxDepth) - if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema) + return provider + } - const parent = request.parent - const run = await provider.start(request) + /** Emit the start/end lifecycle pair for one accepted run and return it. */ + private observeRun(name: string, parent: Agent, run: SubagentRun): SubagentRun { const runId = SubagentRunId(randomUUID()) const lifecycleIdentity = { runId, diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 527d93c8e1..1537b50aa8 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -9,6 +9,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' +import type { SubagentDescriptorData } from './descriptor.ts' /** Identifies one accepted subagent run across its lifecycle event pair. */ export type SubagentRunId = Branded<'SubagentRunId'> @@ -27,9 +28,10 @@ export function SubagentRunId(id: string): SubagentRunId { * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent * degradation" rule). These static flags cover features needed before a run exists; runtime - * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence - * is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option: - * `depthLimit` to `maxDepth`; the other names match. + * capabilities are optional methods whose presence is the capability — strict live steering + * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each + * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to + * `maxDepth`; the other names match. */ export interface SubagentCapabilities { readonly outputSchema: boolean @@ -91,6 +93,56 @@ export interface SubagentStartRequest { * persona (strict `{{…}}` interpolation against the registered variables). */ readonly persona?: string + /** + * Continuable-child intent, resolved by the control service before start. + * The provider MUST publish exactly `sessionId` as the child identity + * instead of allocating one internally, and MUST append the snapshotted + * `descriptor` as the child's turn-enclosed `subagent/descriptor` event + * before its first request. Requires {@link SubagentProvider.resume} (the + * continuation capability); the service rejects the request otherwise. + */ + readonly continuation?: SubagentContinuation +} + +/** + * The resolved continuable-child identity and durable composition record a + * control-service caller attaches to a start request. + */ +export interface SubagentContinuation { + /** Control-allocated stable child session id, published verbatim. */ + readonly sessionId: SessionId + /** Snapshotted descriptor persisted in the child log for cold resume. */ + readonly descriptor: SubagentDescriptorData +} + +/** + * What a caller asks for when resuming a persisted continuable child. The + * control service loads the child log, folds and authorizes its descriptor, + * and passes this fully resolved request to + * {@link SubagentService.resume}, which dispatches to + * {@link SubagentProvider.resume}. The provider reconstructs the declared + * composition under the live parent's scope and drives one turn with `prompt`. + */ +export interface SubagentResumeRequest { + /** The persisted child session id to resume. */ + readonly sessionId: SessionId + /** The follow-up message that starts the resumed activation's turn. */ + readonly prompt: ContentBlock[] + /** + * The live parent agent — the direct parent recorded in the persisted child + * header. In-process backends reconstruct the child under this agent's + * currently loaded scope. + */ + readonly parent: Agent + /** + * Activation-owned cancellation signal, created before descriptor lookup. + * Same pre/post-publication contract as {@link SubagentStartRequest.signal}: + * an abort before publication rejects after rollback quiescence, and an + * abort afterward cancels the published child turn. + */ + readonly signal: AbortSignal + /** The folded durable descriptor whose composition the provider reconstructs. */ + readonly descriptor: SubagentDescriptorData } /** @@ -165,15 +217,16 @@ export interface SubagentRun { */ dispose(): Promise /** - * OPTIONAL (steering capability): send additional content to the running - * child between steps. Present only on providers that support live steering. + * OPTIONAL (strict live-steering capability): deliver additional content to + * the actively running child turn. STRICT means delivery joins the observed + * turn or fails — the implementation must synchronously require the child to + * be running with no asynchronous boundary before delivery, and must not + * fall back to a queue path that could start a new, untracked turn after + * this run has settled. Throws when the child is not running. A run + * represents one disposable activation, so it has no cold-resume operation; + * resuming a settled child goes through {@link SubagentProvider.resume}. */ - sendMessage?(content: ContentBlock[]): void - /** - * OPTIONAL (resume capability): send a follow-up task to a settled child, - * continuing its session, and return a fresh run for the continuation. - */ - resume?(content: ContentBlock[]): Promise + steer?(content: ContentBlock[]): void } /** @@ -201,4 +254,15 @@ export interface SubagentProvider { * promise rejects. Ownership transfers to the caller only on fulfillment. */ start(request: SubagentStartRequest): Promise + /** + * OPTIONAL (continuation capability): reconstruct a persisted continuable + * child from its own transcript and declared descriptor, drive one + * follow-up turn, and return a fresh run. Method presence is the capability + * — the service rejects `resume` dispatch and continuable starts on + * providers without it. Same publication contract as {@link start}: if + * reconstruction fails or `request.signal` aborts before fulfillment, the + * provider rolls its creation transaction back to quiescence before + * rejecting; after fulfillment the same signal cancels the published run. + */ + resume?(request: SubagentResumeRequest): Promise } diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md new file mode 100644 index 0000000000..c4d27e3694 --- /dev/null +++ b/packages/subagent/tool-subagent-control/README.md @@ -0,0 +1,40 @@ +# @deepseek-ai/dsh-tool-subagent-control + +The globally named `send_message` tool: a thin adapter over `ctx.subagentControl.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers the one shared control tool, so multiple delegation tools never register duplicate global controls. + +The tool performs no lifecycle routing. The control service decides between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child; the tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered. + +## Model Experience + +### Tool schema + +#### What the model sees + +The generated [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `subagent_id` and `message`, with delivery-or-continue semantics and the `task_output` collection path described. + +#### Token effect + +Fixed schema cost per parent request. + +#### KV Cache effect + +Prefix-stable; the schema does not change at runtime. + +### Delivery result + +#### What the model sees + +`message delivered to running task ` when the message joined the running activation, or `message started task continuing subagent ` when it cold-resumed the child. Failures are errored results whose message states the message was not delivered (unknown or foreign child, ownership conflict, settlement race, no live-delivery capability). + +#### Token effect + +One short acknowledgement per call; the child's response enters parent history only when collected through `task_output` or injected by the task completion notice. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## Known Limitations and Deferred Work + +- **A delivered message has no independent result** — its effect is reflected in the current Task's eventual result; only a started follow-up owns a fresh Task result. +- **Delivery can lose timing races** — a message racing task settlement, cancellation, or cleanup fails explicitly rather than falling through to cold resume; the model retries after the task settles. diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json new file mode 100644 index 0000000000..96c91c4ec6 --- /dev/null +++ b/packages/subagent/tool-subagent-control/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-tool-subagent-control", + "description": "Globally named send_message tool over the continuable-subagent control service", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent-control": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-control": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts new file mode 100644 index 0000000000..3c537f471c --- /dev/null +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -0,0 +1,74 @@ +/** + * The globally named `send_message` tool: a thin model-facing adapter over + * `ctx.subagentControl.sendMessage()`. It performs no lifecycle routing of its + * own — steer-or-resume orchestration belongs to the control service — and it + * lives apart from the provider-bound `@deepseek-ai/dsh-tool-subagent` + * instances so multiple delegation tools share one control tool. + * @module @deepseek-ai/dsh-tool-subagent-control + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-subagent-control' + +export const name = 'tool-subagent-control' +export const inject = ['tools', 'subagentControl'] + +/** + * Register the `send_message` tool. + * @param ctx - context carrying the tool registry and the control service. + */ +export function apply(ctx: Context): void { + ctx.tools.register(defineTool({ + name: 'send_message', + description: + 'Send a follow-up message to a background subagent by its subagent id. If it is still working, the ' + + 'message joins its current task; if it has finished, this starts a new task that continues the same ' + + 'subagent conversation. Either way the response arrives through the returned task id — collect it ' + + 'with `task_output`. A failure means the message was NOT delivered.', + parameters: { + subagent_id: { + type: 'string', + required: true, + description: 'The subagent id returned when the background subagent was started.', + }, + message: { + type: 'string', + required: true, + description: 'The message to deliver to the subagent.', + }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + route: { + type: 'string', + required: true, + enum: ['steered', 'started'], + }, + taskId: { type: 'string', required: true }, + }, + }, + render: (args, value) => [{ + type: 'text', + text: value.route === 'steered' + ? `message delivered to running task ${value.taskId}` + : `message started task ${value.taskId} continuing subagent ${args.subagent_id}`, + }], + }, + execute(args, exec) { + const parent = exec.agent + if (!parent) { + // Non-agent callers have no session to authorize Task access with. + throw new Error('send_message requires a calling agent (exec.agent was undefined)') + } + const message: ContentBlock[] = [{ type: 'text', text: args.message }] + const result = ctx.subagentControl.sendMessage(parent, SessionId(args.subagent_id), message) + return Promise.resolve(result) + }, + })) +} diff --git a/packages/subagent/tool-subagent-control/src/invariant.ts b/packages/subagent/tool-subagent-control/src/invariant.ts new file mode 100644 index 0000000000..6fb1c19ea6 --- /dev/null +++ b/packages/subagent/tool-subagent-control/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-subagent-control`. + * @module @deepseek-ai/dsh-tool-subagent-control/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent-control' + +/** Cordis companion plugin name. */ +export const name = 'tool-subagent-control-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; delivery + * and activation relations are owned by the control service it calls. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts new file mode 100644 index 0000000000..b54eb6508a --- /dev/null +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SubagentService from '@deepseek-ai/dsh-subagent' +import SubagentControlService from '@deepseek-ai/dsh-subagent-control' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as tool from '../src/index.ts' + +const testToolSignal = new AbortController().signal + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +async function setup(script: ConstructorParameters[0]) { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks, {}) + await ctx.plugin(SubagentControlService) + await ctx.plugin(tool) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent } +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +let calls = 0 +function callTool(ctx: Context, name: string, args: unknown, agent?: unknown) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`call-${++calls}`), + name, + arguments: args, + ...agent !== undefined ? { agent: agent as never } : {}, + }) +} + +describe('dsh-tool-subagent-control', () => { + it('registers send_message once, globally, with the two required parameters', async () => { + const { ctx } = await setup([]) + const schemas = ctx.tools.schemas().filter(schema => schema.name === 'send_message') + expect(schemas).toHaveLength(1) + const props = (schemas[0]!.parameters as { properties?: Record }).properties ?? {} + expect(Object.keys(props).sort()).toEqual(['message', 'subagent_id']) + expect(schemas[0]!.description).toContain('task_output') + }) + + it('cold-resumes a settled child and renders the started route with its task id', async () => { + const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) + const started = ctx.subagentControl.startContinuable({ + provider: 'spawn', + label: 'work', + request: { prompt: [{ type: 'text', text: 'child task' }], parent }, + }) + await ctx.tasks.wait(started.taskId, 5_000, parent) + + const result = await callTool(ctx, 'send_message', { + subagent_id: started.childId, + message: 'and then?', + }, parent) + expect(result.isError).toBe(false) + expect(text(result)).toBe(`message started task subagent-2 continuing subagent ${started.childId}`) + const collected = await callTool(ctx, 'task_output', { task_id: 'subagent-2', wait: true }, parent) + expect(text(collected)).toBe('second answer\n[status: completed]') + }) + + it('renders the steered route when the child is still running', async () => { + // Script the child's single turn as two steps: the steer joins mid-turn. + const { ctx, parent } = await setup([]) + let steered: string | undefined + // Reach past the tool into the control service to fake a running route + // deterministically: the tool is a thin adapter, so its steered wording is + // what this test pins. + ctx.subagentControl.sendMessage = (agent, _childId, message) => { + steered = (message[0] as { text: string }).text + return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } + } + const result = await callTool(ctx, 'send_message', { + subagent_id: 'some-child', + message: 'also consider Y', + }, parent) + expect(result.isError).toBe(false) + expect(steered).toBe('also consider Y') + expect(text(result)).toBe('message delivered to running task subagent-9') + }) + + it('reports a control-service failure as an errored, not-delivered result', async () => { + const { ctx, parent } = await setup([]) + const result = await callTool(ctx, 'send_message', { + subagent_id: 'no-such-child', + message: 'hello?', + }, parent) + // Unknown ids start a Task whose failure carries the unavailable detail; + // synchronous rejections (ownership conflicts) become isError results. + if (result.isError) { + expect(text(result)).toContain('not delivered') + } else { + const taskId = text(result).match(/task (\S+) /)?.[1] + expect(taskId).toBeDefined() + const snapshot = await ctx.tasks.wait(taskId as never, 5_000, parent) + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('unavailable') + } + }) + + it('fails loud when invoked without a calling agent', async () => { + const { ctx } = await setup([]) + const result = await callTool(ctx, 'send_message', { subagent_id: 'x', message: 'y' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('requires a calling agent') + }) + + it('unregisters with its plugin fiber (HMR safety)', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalTaskService) + await ctx.plugin(SubagentControlService) + const fiber = await ctx.plugin(tool) + expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true) + await fiber.dispose() + expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false) + }) + + it('has the namespace-plugin export shape (no stray default)', () => { + expect('default' in tool).toBe(false) + expect(tool.name).toBe('tool-subagent-control') + expect(tool.inject).toEqual(['tools', 'subagentControl']) + expect(typeof tool.apply).toBe('function') + }) +}) diff --git a/packages/subagent/tool-subagent-control/tsconfig.json b/packages/subagent/tool-subagent-control/tsconfig.json new file mode 100644 index 0000000000..4b2ec045e6 --- /dev/null +++ b/packages/subagent/tool-subagent-control/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/tools" + }, + { + "path": "../subagent-control" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 9c8e235669..c4660b5517 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/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/subagent/tool-subagent/README.md -README.md: 20bb6b9c59a13f23301368faefe18849ccc0b1e9 -README.zh.md: 8da57896359f5ac47d0ec076c3395d2e7fb1e02a +README.md: 7d32da3c974361eb5e58cdb2ee5be756383ad3d1 +README.zh.md: eadc168fd07701b3e3d9600b3fe69bd8b22e235a diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 20bb6b9c59..7d32da3c97 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -10,7 +10,7 @@ Each plugin instance binds one `provider` to one `toolName`; the model receives A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output. -With `run_in_background: true`, the tool registers the parent-owned task before starting the provider and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md). +With `run_in_background: true`, the route follows the provider's continuation capability and returns canonical `{ kind: 'background', taskId, subagentId? }`. A resumable provider (spawn, fork) delegates to `ctx.subagentControl.startContinuable()`, which owns the durable child id, descriptor snapshot, Task registration, and settle-then-dispose ordering; the result includes `subagentId`, renders as `started subagent as task `, and accepts follow-up messages through the global `send_message` tool. A one-shot provider (ACP) keeps the plain parent-owned task, omits `subagentId`, and renders as `started background subagent task `. Either way a task-owned signal covers pending startup and the child after the starting call returns; `task_kill` and owner disposal abort it, settlement awaits startup rollback or child disposal, and completed final text, abort to `killed`, and other failures to `failed` map identically. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md) and the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md). `toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). @@ -64,7 +64,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Start returns exactly `started background subagent task `. The generic task surface provides later status, final output, cancellation responses, and notices. +Start returns exactly `started subagent as task ` on a resumable provider, or `started background subagent task ` on a one-shot provider. The generic task surface provides later status, final output, cancellation responses, and notices; `send_message` (from `dsh-tool-subagent-control`) delivers follow-ups to a continuable child. #### Token effect diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 8da5789635..eadc168fd0 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -10,7 +10,7 @@ 前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。 -设置 `run_in_background: true` 后,工具会在启动提供方前注册父级拥有的任务,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task `。任务拥有的信号覆盖待处理的启动阶段,以及启动调用返回后的子 agent。`task_kill` 和所有者 dispose(资源释放)会中止它。结算会等待启动回滚或子 agent dispose,然后把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。任务不提供增量读取;通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)。 +设置 `run_in_background: true` 后,路由遵循提供方的继续功能,并返回规范值 `{ kind: 'background', taskId, subagentId? }`。可恢复提供方(spawn、fork)会委派给 `ctx.subagentControl.startContinuable()`,由它拥有持久化子 agent ID、描述符快照、Task 注册和先结算后 dispose(资源释放)的顺序;结果包含 `subagentId`,渲染为 `started subagent as task `,并通过全局 `send_message` 工具接收后续消息。一次性提供方 ACP(Agent Client Protocol)保留普通的父级所有任务,省略 `subagentId`,并渲染为 `started background subagent task `。两条路径中,任务拥有的信号都会覆盖待处理的启动阶段和启动调用返回后的子 agent;`task_kill` 和所有者 dispose 会中止它,结算会等待启动回滚或子 agent dispose,然后把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。任务不提供增量读取;通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)和[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -64,7 +64,7 @@ #### 模型看到的内容 -启动时原样返回 `started background subagent task `。通用任务接口提供后续状态、最终输出、取消响应和通知。 +对于可恢复提供方,启动时精确返回 `started subagent as task `;对于一次性提供方,则返回 `started background subagent task `。通用任务接口提供后续状态、最终输出、取消响应和通知;`send_message`(来自 `dsh-tool-subagent-control`)会把后续消息交付给可继续子 agent。 #### Token 影响 diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index b5c7b5d94f..d789c9b4f9 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subagent-control": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -43,7 +44,12 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-control": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index a89abaa139..1b8f2e4cbf 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -1,20 +1,23 @@ /** * Model-facing delegation through one configured `ctx.subagents` provider. * Provider lifecycle controls tool registration and context-sensitive schema - * wording. Foreground calls always dispose the run after collection; background - * calls use an independent cancellation signal and settle a final-output task - * only after child disposal. + * wording. Foreground calls always dispose the run after collection. A + * background call's route follows the provider's continuation capability: + * a provider with `resume` delegates to `ctx.subagentControl`, which owns the + * durable child id, its descriptor, and the Task-backed activation lifecycle; + * a provider without it (ACP) keeps the one-shot background task. * @module @deepseek-ai/dsh-tool-subagent */ import type { Context } from 'cordis' import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-session' import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' -import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent' +import { settleRun } from '@deepseek-ai/dsh-subagent-control' import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' export const name = 'tool-subagent' @@ -85,18 +88,6 @@ export const Config: z = z.object({ maxDepth: z.union([z.natural().max(Number.MAX_SAFE_INTEGER), z.const('provider-managed' as const)]).default(3), }) -/** - * Flatten a child's final output blocks to text for the tool result. The child - * may return non-text blocks; this path returns only text. Structured results - * use `outputSchema`. - */ -function outputText(blocks: ContentBlock[]): string { - return blocks - .filter((b): b is Extract => b.type === 'text') - .map(b => b.text) - .join('') -} - /** Render text blocks from the canonical JSON block array without trusting arbitrary values. */ function outputValueText(values: JsonValue[]): string { return values @@ -107,6 +98,17 @@ function outputValueText(values: JsonValue[]): string { .join('') } +/** Settle pending startup without rejecting the task producer contract. */ +async function settleStart(start: Promise, signal: AbortSignal): Promise { + try { + return await settleRun(await start) + } catch (error: unknown) { + return signal.aborted + ? { status: 'killed' } + : { status: 'failed', detail: String(error) } + } +} + /** A non-`completed` stop reason means the child did not finish cleanly. */ function stopReasonError(result: SubagentResult): string | undefined { switch (result.stopReason) { @@ -127,50 +129,6 @@ function stopReasonError(result: SubagentResult): string | undefined { } } -/** - * Map a child result to the task outcome: completed carries final text, - * aborted is killed, and every other reason is failed without partial output. - * @param result - child terminal result. - * @returns outcome for the `ctx.tasks` registration. - */ -export function runOutcome(result: SubagentResult): TaskOutcome { - switch (result.stopReason) { - case 'completed': - return { status: 'completed', output: outputText(result.output) } - case 'aborted': - return { status: 'killed' } - case 'error': - case 'max-tokens': - case 'refusal': - return { status: 'failed', detail: result.stopReason } - // Merge-extensible reasons remain failures with their raw detail. - default: - return { status: 'failed', detail: String(result.stopReason) } - } -} - -/** - * Await the child result, dispose the run, then return its task outcome. Result - * and disposal failures become `failed`; when both fail, both details survive. - * @param run - live run to settle and release. - * @returns outcome after child resources are released. - */ -export async function settleRun(run: SubagentRun): Promise { - let outcome: TaskOutcome - try { - outcome = runOutcome(await run.result) - } catch (error: unknown) { - outcome = { status: 'failed', detail: String(error) } - } - try { - await run.dispose() - } catch (error: unknown) { - const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; ` - return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` } - } - return outcome -} - /** * Model-facing wording from the provider's conversation-history descriptor * ({@link SubagentProvider.inheritsParentContext}). @@ -210,30 +168,6 @@ function providerWording(inheritsConversation: boolean): { description: string; } } -function startRequest(config: Config, prompt: string, parent: Agent, signal: AbortSignal): SubagentStartRequest { - const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined - return { - prompt: [{ type: 'text', text: prompt }], - parent, - signal, - ...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {}, - ...config.persona !== undefined ? { persona: config.persona } : {}, - ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, - ...maxDepth !== undefined ? { maxDepth } : {}, - } -} - -/** Settle pending startup without rejecting the task producer contract. */ -async function settleStart(start: Promise, signal: AbortSignal): Promise { - try { - return await settleRun(await start) - } catch (error: unknown) { - return signal.aborted - ? { status: 'killed' } - : { status: 'failed', detail: String(error) } - } -} - export function apply(ctx: Context, config: Config): void { // Direct apply() bypasses Schemastery's numeric constraints. A direct-apply // omission stays capless (the schema default only runs through the loader). @@ -257,10 +191,18 @@ export function apply(ctx: Context, config: Config): void { } const wording = providerWording(provider.inheritsParentContext) const backgroundEnabled = config.enableRunInBackground !== false + // The provider's continuation capability decides the background route: a + // resumable provider starts durable, follow-up-able children through the + // control service, while a one-shot provider (ACP) keeps the plain task. + const continuable = provider.resume !== undefined disposeTool = ctx.tools.register(defineTool({ name: config.toolName ?? 'subagent', description: wording.description + (backgroundEnabled - ? ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' + ? continuable + ? ' Set `run_in_background: true` to start a continuable background subagent: you receive its' + + ' subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`,' + + ' and send follow-up messages with `send_message`.' + : ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' : ''), parameters: { description: { @@ -276,7 +218,10 @@ export function apply(ctx: Context, config: Config): void { ...backgroundEnabled ? { run_in_background: { type: 'boolean' as const, - description: 'Run as a background task and return its id; collect with task_output or stop with task_kill.', + description: continuable + ? 'Run as a continuable background subagent and return its subagent and task ids; ' + + 'collect with task_output, stop with task_kill, follow up with send_message.' + : 'Run as a background task and return its id; collect with task_output or stop with task_kill.', }, } : {}, }, @@ -289,6 +234,7 @@ export function apply(ctx: Context, config: Config): void { properties: { kind: { type: 'string', required: true, const: 'background' }, taskId: { type: 'string', required: true }, + subagentId: { type: 'string' }, }, }, { @@ -305,7 +251,9 @@ export function apply(ctx: Context, config: Config): void { render: (_args, value) => [{ type: 'text', text: value.kind === 'background' - ? `started background subagent task ${value.taskId}` + ? value.subagentId === undefined + ? `started background subagent task ${value.taskId}` + : `started subagent ${value.subagentId} as task ${value.taskId}` : outputValueText(value.output), }], }, @@ -316,27 +264,54 @@ export function apply(ctx: Context, config: Config): void { throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') } + const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined + const request = { + prompt: [{ type: 'text', text: args.prompt }] as ContentBlock[], + parent, + ...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {}, + ...config.persona !== undefined ? { persona: config.persona } : {}, + ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, + ...maxDepth !== undefined ? { maxDepth } : {}, + } + if (args.run_in_background === true) { // The validator permits undeclared keys, so schema omission also needs // execution-time enforcement. if (!backgroundEnabled) { throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)') } + if (continuable) { + const control = ctx.get('subagentControl') + if (control === undefined) { + throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-subagent-control and @deepseek-ai/dsh-tool-tasks') + } + // The control service owns the durable child id, descriptor + // snapshot, Task registration, and settle-then-dispose ordering; a + // synchronous validation failure rejects the call with no Task. + const started = control.startContinuable({ + provider: config.provider, + label: args.description, + request, + }) + return { + kind: 'background' as const, + taskId: started.taskId, + subagentId: started.childId, + } + } const tasks = ctx.get('tasks') if (tasks === undefined) { throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') } - // Task preflight finishes before the starter can spawn a child. + // One-shot background child: task preflight finishes before the + // starter can spawn, and the task-owned signal covers startup. const id = tasks.start({ kind: 'subagent', label: args.description, owner: parent, run: () => { const controller = new AbortController() - const start = ctx.subagents.start( - config.provider, - startRequest(config, args.prompt, parent, controller.signal), - ) + const start = ctx.subagents.start(config.provider, { ...request, signal: controller.signal }) return { cancel: (reason?: string) => { controller.abort(reason ?? 'background subagent task killed') @@ -349,14 +324,10 @@ export function apply(ctx: Context, config: Config): void { return { kind: 'background' as const, taskId: id } } - const request = startRequest( - config, - args.prompt, - parent, - exec.signal, - ) - - const run: SubagentRun = await ctx.subagents.start(config.provider, request) + const run: SubagentRun = await ctx.subagents.start(config.provider, { + ...request, + signal: exec.signal, + }) try { const result = await run.result diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 5c27a09e2b..41b383bf9e 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' @@ -6,13 +9,18 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { type Agent } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import SubagentControlService from '@deepseek-ai/dsh-subagent-control' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' -import { runOutcome, settleRun } from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' const testToolSignal = new AbortController().signal @@ -808,58 +816,75 @@ describe('dsh-tool-subagent background mode', () => { expect(text(killed)).toBe('(no new output)\n[status: killed]') }) - it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => { - const output = [{ type: 'text' as const, text: 'partial' }] - expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' }) - expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' }) - expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' }) - expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' }) - expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' }) - // Merge-extensible: an unknown reason is failed-with-detail, never success. - expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' }) +}) + +describe('dsh-tool-subagent continuable background mode', () => { + const roots: string[] = [] + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) - it('settleRun disposes the run before reporting, on both result paths', async () => { - const order: string[] = [] - const completed = await settleRun({ - id: SessionId('child-1'), - localAgent: undefined, - result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }), - dispose() { order.push('dispose'); return Promise.resolve() }, - }) - order.push('reported') - expect(completed).toEqual({ status: 'completed', output: 'ok' }) - expect(order).toEqual(['dispose', 'reported']) + /** Boot the real continuable stack: loop, persistence, spawn, tasks, control. */ + async function continuableSetup() { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks, {}) + await ctx.plugin(SubagentControlService) + await ctx.plugin(tool, { provider: 'spawn' }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([ + textResponse('continuable answer'), + ])) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent } + } - // An infrastructure rejection still disposes and reports failed. - let disposed = false - const failed = await settleRun({ - id: SessionId('child-2'), - localAgent: undefined, - result: Promise.reject(new Error('transport gone')), - dispose() { disposed = true; return Promise.resolve() }, - }) - expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' }) - expect(disposed).toBe(true) + it('a resumable provider advertises send_message and returns both ids', async () => { + const { ctx, parent } = await continuableSetup() + const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! + expect(schema.description).toContain('send_message') - const disposeFailed = await settleRun({ - id: SessionId('child-3'), - localAgent: undefined, - result: Promise.resolve({ output: [], stopReason: 'completed' }), - dispose: () => Promise.reject(new Error('reap failed')), - }) - expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' }) + const started = await callSubagent( + ctx, + { description: 'continuable work', prompt: 'dig in', run_in_background: true }, + { agent: parent }, + ) + expect(started.isError).toBe(false) + const match = /^started subagent (\S+) as task (\S+)$/.exec(text(started)) + expect(match).not.toBeNull() + const [, childId, taskId] = match! + const snapshot = await ctx.tasks.wait(taskId as never, 5_000, parent) + expect(snapshot.status).toBe('completed') + expect(ctx.tasks.read(taskId as never, parent).text).toBe('continuable answer') + // The child id names a durable session that outlives the settled Task. + const loaded = await ctx.sessionPersistence.load(SessionId(childId!)) + expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) + }) - const bothFailed = await settleRun({ - id: SessionId('child-4'), - localAgent: undefined, - result: Promise.reject(new Error('result failed')), - dispose: () => Promise.reject(new Error('reap failed')), - }) - expect(bothFailed).toEqual({ - status: 'failed', - detail: 'Error: result failed; dispose failed: Error: reap failed', + it('fails loud when the provider is resumable but the control service is not loaded', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + // A resumable provider without ctx.subagentControl. + ctx.subagents.registerProvider({ + name: 'resumable', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: () => { throw new Error('unreachable') }, + resume: () => { throw new Error('unreachable') }, }) + await ctx.plugin(tool, { provider: 'resumable', maxDepth: 'provider-managed' }) + + const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('load @deepseek-ai/dsh-subagent-control') }) }) diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json index 25780c367f..a542b520b1 100644 --- a/packages/subagent/tool-subagent/tsconfig.json +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../subagent" }, + { + "path": "../subagent-control" + }, { "path": "../../tasks/tasks" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a8b7f4a8..534f592f65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -731,6 +731,9 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-control': + specifier: workspace:* + version: link:../packages/subagent/subagent-control '@deepseek-ai/dsh-subagent-dsh-sdk': specifier: workspace:* version: link:../packages/subagent/subagent-dsh-sdk @@ -800,6 +803,9 @@ importers: '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:* + version: link:../packages/subagent/tool-subagent-control '@deepseek-ai/dsh-tool-tasks': specifier: workspace:* version: link:../packages/tasks/tool-tasks @@ -4920,6 +4926,51 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/subagent/subagent-control: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subagent-fork': + specifier: workspace:^ + version: link:../subagent-fork + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../subagent-spawn + '@deepseek-ai/dsh-tasks': + specifier: workspace:^ + version: link:../../tasks/tasks + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../tasks/tasks-local + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:^ + version: link:../../tasks/tool-tasks + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/subagent/subagent-dsh-sdk: dependencies: schemastery: @@ -5115,9 +5166,24 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent + '@deepseek-ai/dsh-subagent-control': + specifier: workspace:^ + version: link:../subagent-control + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../subagent-spawn '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -5137,6 +5203,57 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/subagent/tool-subagent-control: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subagent-control': + specifier: workspace:^ + version: link:../subagent-control + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../subagent-spawn + '@deepseek-ai/dsh-tasks': + specifier: workspace:^ + version: link:../../tasks/tasks + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../tasks/tasks-local + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:^ + version: link:../../tasks/tool-tasks + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/subprocess/subprocess: devDependencies: '@deepseek-ai/dsh-invariants': @@ -6387,6 +6504,9 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:^ version: link:../../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-control': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-control '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../packages/subagent/subagent-fork @@ -6447,6 +6567,9 @@ importers: '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent-control '@deepseek-ai/dsh-tool-tasks': specifier: workspace:^ version: link:../../packages/tasks/tool-tasks diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 5af9f4fc8c..df5d302915 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -66,6 +66,7 @@ "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-acp": "workspace:^", + "@deepseek-ai/dsh-subagent-control": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", @@ -86,6 +87,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 16fa0265d6..61d7894711 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { "AGENTS.md": 1775, "docs/AGENTS.md": 1150, - "docs/architecture.md": 1920, + "docs/architecture.md": 2040, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1100, diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 97685a9086..d8c7ee2715 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -160,7 +160,11 @@ export const LINK_MAP: Readonly> = { SkillSummary: 'skills.md', SaveTextSpill: 'spill.md', SpillRef: 'spill.md', + ContinuableStart: 'subagent.md', + ContinuableStartSpec: 'subagent.md', + SendMessageResult: 'subagent.md', SubagentProvider: 'subagent.md', + SubagentResumeRequest: 'subagent.md', SubagentRun: 'subagent.md', SubagentService: 'subagent.md', SubagentStartRequest: 'subagent.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 77c0302dce..02215862db 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -430,6 +430,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-subagent', 'tool-ralph'], note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.', }, + { + key: 'subagentControl', + pkg: 'subagent', + title: 'Continuable-subagent control service', + mode: 'core', + consumers: ['tool-subagent', 'tool-subagent-control'], + note: 'Binds one durable child session to Task-backed activations over ctx.subagents; tool-subagent starts continuable background children and tool-subagent-control delivers follow-up messages.', + }, { key: 'tasks', pkg: 'tasks', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index a77bdb6d95..9d3907821a 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -28,6 +28,8 @@ import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' +import SubagentControlService from '@deepseek-ai/dsh-subagent-control' +import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' @@ -106,6 +108,9 @@ function registerCatalogSubagentProvider(ctx: Context, name: string): void { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')), + // Presence marks the continuation capability, so tool-subagent harvests + // its shipped continuable background wording (spawn/fork are resumable). + resume: () => Promise.reject(new Error('tool-catalog provider cannot resume a child')), } ctx.subagents.registerProvider(provider) } @@ -379,6 +384,22 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`.', }, + { + pkg: '@deepseek-ai/dsh-tool-subagent-control', + dir: 'tool-subagent-control', + source: 'packages/subagent/tool-subagent-control/src/index.ts', + requires: ['ctx.tools', 'ctx.subagentControl'], + writes: ['tool/call', 'tool/result', 'child session events through the control service'], + async mount(ctx) { + await ctx.plugin(SubagentService) + await ctx.plugin(LocalTaskService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SubagentControlService) + await ctx.plugin(ToolSubagentControl) + }, + note: + 'The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once.', + }, { pkg: '@deepseek-ai/dsh-tool-tasks', dir: 'tool-tasks', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index da1375b1d6..7dfd3195a8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1094,6 +1094,16 @@ "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentContinuation", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentResumeRequest", + "source": "packages/subagent/subagent/src/types.ts" + }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", diff --git a/tsconfig.host.json b/tsconfig.host.json index ef72d6a43d..3f46bf5ee6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -178,7 +178,9 @@ { "path": "./packages/support/loader-smoke" }, { "path": "./packages/support/llm-mock-server" }, { "path": "./packages/subagent/subagent" }, + { "path": "./packages/subagent/subagent-control" }, { "path": "./packages/subagent/tool-subagent" }, + { "path": "./packages/subagent/tool-subagent-control" }, { "path": "./packages/subagent/subagent-inprocess" }, { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, From 3be7ca8664a120cb565891aeb3f6aa0fbdae681e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 17:14:28 +0800 Subject: [PATCH 028/114] test(loader-smoke): normalize /private/tmp temp paths macOS realpaths temp dirs into /private. The normalizer only stripped the /private prefix for TMPDIR under /var; a TMPDIR under /tmp (any explicitly relocated temp root) left one side canonicalized and the comparison failing. Accept both shapes. --- packages/support/loader-smoke/tests/loader-smoke.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts index 7a803f2c80..5124147cd8 100644 --- a/packages/support/loader-smoke/tests/loader-smoke.spec.ts +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -8,7 +8,8 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l const configPath = '/tmp/fixture.cordis.yml' const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) const fixture = (name: string): string => fileURLToPath(new URL(`./fixtures/${name}.ts`, import.meta.url)) -const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/var\/)/, '') +// macOS realpaths temp dirs into /private; TMPDIR may live under /var or /tmp. +const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/(?:var|tmp)\/)/, '') describe('runLoaderSmoke', () => { it('isolates the process, closes stdin, captures output, and removes the cwd', async () => { From 71570d7becc259365705868219163c23e14190ff Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 17:53:20 +0800 Subject: [PATCH 029/114] fix: address codex review round 1 - Strict steer now rejects the two windows where an acknowledged message would be silently dropped: the closed-turn durability-flush window (status still running, loop strands drained steering) and a committed structured capture (terminal turn-stop discards late steering). Seam JSDoc, catalog doc, README, and the Agent Note bilingual pair state the tightened contract; new keyless tests pin both rejections. - Continuable background delegation now fails loud when the advertised send_message tool is not registered, instead of starting a durable child the model cannot continue. The acp-agent example already loads the control tool; the tool-catalog boot recipe is unaffected because capability wording is harvested at mount. --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 2 +- ...-21-continuable-background-subagents.zh.md | 2 +- docs/core-data-structures/subagent.md | 9 ++-- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 19 ++++++++- .../tests/structured.spec.ts | 27 ++++++++++++ .../tests/subagent-inprocess.spec.ts | 41 +++++++++++++++++++ packages/subagent/subagent/src/types.ts | 9 ++-- packages/subagent/tool-subagent/src/index.ts | 7 ++++ .../tool-subagent/tests/tool-subagent.spec.ts | 19 ++++++++- 11 files changed, 126 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index d8bdceb531..3b5eaed10d 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-continuable-background-subagents.md: 25ae582b129b2e2dc4a34c6fb3c0247aa644677a -2026-07-21-continuable-background-subagents.zh.md: f7a09ce0519874dad8b32835d0b43914a37350c8 +2026-07-21-continuable-background-subagents.md: abb8a89bd6ec0fbe4a36e7f82c1356fb96b38390 +2026-07-21-continuable-background-subagents.zh.md: 30207dfd757eeada3e8ba961b67c79db3c98aad6 diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index 25ae582b12..abb8a89bd6 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -49,7 +49,7 @@ For a continuable initial activation, the control service allocates the stable c Every continuable child turn is admitted through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the control service synchronously compares its association with `ctx.agents.get(childId)`. A registry Agent with no association, or a registry Agent different from the associated `run.localAgent`, is an ownership conflict: the control service fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary. -Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability by synchronously requiring `AgentStatus.running` before calling `Agent.steer()`; the check and call contain no asynchronous boundary. Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict check, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. +Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability with synchronous checks that share one frame with the `Agent.steer()` call: the child must be `running`, its turn must still be open in the log (status stays `running` through a closed turn's durability flush, where the loop strands drained steering), and no structured capture may have committed (its terminal stop makes the loop discard late steering). Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict check, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. The control service does not serialize two callers that race a stopped child through paths outside it, nor does it model a separate settling phase between result production and disposal. The synchronous association install before the producer's first await admits one activation per child in this process — a competing `sendMessage` during resume load observes the pending activation and fails explicitly — while a bypassing publication still loses at the Agent registry's same-session collision boundary. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index f7a09ce051..30207dfd75 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -49,7 +49,7 @@ durable child Session 每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,控制服务会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:控制服务会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。 -系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 通过以下方式实现该功能:调用 `Agent.steer()` 前同步要求 `AgentStatus.running`,检查与调用之间不存在异步边界。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 用与 `Agent.steer()` 调用共享同一同步帧的检查来实现该功能:child 必须处于 `running` 状态,其轮次在日志中必须仍然打开(已关闭轮次的持久化 flush 期间状态仍是 `running`,此时循环会丢弃排空的 steering 消息),且不得已有结构化捕获提交(其终止性 stop 会让循环丢弃迟到的 steering)。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index ec69056602..b5ec558351 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -240,10 +240,11 @@ interface SubagentRun { /** * OPTIONAL (strict live-steering capability): deliver additional content to * the actively running child turn. STRICT means delivery joins the observed - * turn or fails — the implementation must synchronously require the child to - * be running with no asynchronous boundary before delivery, and must not - * fall back to a queue path that could start a new, untracked turn after - * this run has settled. Throws when the child is not running. A run + * turn or fails — the implementation must synchronously verify, with no + * asynchronous boundary before delivery, that the child is running and its + * turn can still record the message, and must not fall back to a queue path + * that could start a new, untracked turn or silently drop the message after + * this run has settled. Throws when delivery cannot join the turn. A run * represents one disposable activation, so it has no cold-resume operation; * resuming a settled child goes through {@link SubagentProvider.resume}. */ diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 7587b6dfc4..f0660b1a5b 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -30,7 +30,7 @@ The required request signal covers both startup and the live run. Before publica After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed. -Runs expose the strict `steer` capability: a synchronous `AgentStatus.running` check and `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. +Runs expose the strict `steer` capability: the synchronous checks and the `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. Delivery requires `AgentStatus.running`, an open turn in the child log (status stays `running` through a closed turn's durability flush, where the loop would strand the message), and no committed structured capture (whose terminal stop makes the loop discard late steering). The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. ## Spawn and fork inputs diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 695283a027..176acb4d70 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -259,13 +259,30 @@ function driveTurn( return handle.dispose() }, steer(content: ContentBlock[]): void { - // Strict live delivery: the synchronous running check and Agent.steer() + // Strict live delivery: the synchronous checks and the Agent.steer() // call share one frame, so delivery joins the observed turn or throws. // Agent.steer()'s own idle fallback would instead QUEUE the message and // start a new, untracked turn after this run's result was read. if (child.status !== 'running') { throw new Error(`subagent child "${childId}" is not running; the message was not delivered`) } + // The status stays `running` through the closed turn's durability flush, + // and the loop DISCARDS terminal-stopped steering drained after turn + // close instead of recording it. Requiring an open turn keeps + // acknowledged delivery honest. + const lastBoundary = child.session.events.findLast( + event => event.type === 'turn/start' || event.type === 'turn/end', + ) + if (lastBoundary?.type !== 'turn/start') { + throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`) + } + // A committed structured capture makes the pending `agent/turn-stop` + // checkpoint terminal, and the loop then discards late steering. The + // capture is synchronously observable, so reject rather than + // acknowledge a message the run is about to drop. + if (structured?.captured() !== undefined) { + throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`) + } child.steer(createUserMessage({ content, source: { kind: 'user' } })) }, } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index ff6db01285..62ef25e095 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -120,6 +120,33 @@ describe('in-process structured output', () => { await run.dispose() }) + it('strict steer rejects delivery once the structured result is captured', async () => { + // Hold the capture's tool result open so the child is observably running + // with a committed capture: the pending agent/turn-stop checkpoint is + // terminal, and the loop would DISCARD a steering message, so an + // acknowledged delivery here would be a lie. + let releaseResult: (() => void) | undefined + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), + ]) + ctx.on('agent/post-step', (agent) => { + if (agent.session.header.parentSession === undefined || releaseResult !== undefined) return + return new Promise((resolve) => { releaseResult = resolve }) + }) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + await new Promise((resolve) => { + const timer = setInterval(() => { + if (releaseResult !== undefined) { clearInterval(timer); resolve() } + }, 5) + }) + expect(() => { run.steer!([{ type: 'text', text: 'one more thing' }]) }) + .toThrow(/already reported its structured result; the message was not delivered/) + releaseResult!() + const result = await run.result + expect(result.structured).toEqual({ answer: 7 }) + await run.dispose() + }) + it('denies tool calls that FOLLOW the capture in the same response — terminal means terminal', async () => { // One model response carrying structured_output FIRST and a side-effecting // call after it: the continuation veto only fires at step end, so without diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index fb631ee10e..1de7329ecc 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -245,4 +245,45 @@ describe('startInProcessRun', () => { expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) + + it('strict steer rejects a settled child instead of queueing an untracked turn', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const run = await startInProcessRun(request(parent), {}) + await run.result + // The child is idle after its turn: Agent.steer() would silently QUEUE. + expect(() => { run.steer!([{ type: 'text', text: 'late' }]) }) + .toThrow(/not running; the message was not delivered/) + const child = ctx.agents.get(run.id)! + expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) + await run.dispose() + }) + + it('strict steer rejects the closed-turn flush window where the loop discards steering', async () => { + // Hold the turn-end durability flush open: the turn has closed in the log + // and status is still `running`, exactly the window where the loop would + // discard a drained steering message instead of recording it. + const { ctx, parent } = await setup([textResponse('quick')]) + let releaseFlush: (() => void) | undefined + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined || releaseFlush !== undefined) return + const lastEnd = session.events.findLast(event => event.type === 'turn/end') + if (lastEnd === undefined) return + return new Promise((resolve) => { releaseFlush = resolve }) + }) + const run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + // Wait until the child's turn has closed while the flush keeps it running. + await new Promise((resolve) => { + const timer = setInterval(() => { + if (releaseFlush !== undefined) { clearInterval(timer); resolve() } + }, 5) + }) + expect(child.status).toBe('running') + expect(() => { run.steer!([{ type: 'text', text: 'into the void' }]) }) + .toThrow(/turn has already closed; the message was not delivered/) + releaseFlush!() + await run.result + expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) + await run.dispose() + }) }) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 1537b50aa8..8cc88eb0c2 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -219,10 +219,11 @@ export interface SubagentRun { /** * OPTIONAL (strict live-steering capability): deliver additional content to * the actively running child turn. STRICT means delivery joins the observed - * turn or fails — the implementation must synchronously require the child to - * be running with no asynchronous boundary before delivery, and must not - * fall back to a queue path that could start a new, untracked turn after - * this run has settled. Throws when the child is not running. A run + * turn or fails — the implementation must synchronously verify, with no + * asynchronous boundary before delivery, that the child is running and its + * turn can still record the message, and must not fall back to a queue path + * that could start a new, untracked turn or silently drop the message after + * this run has settled. Throws when delivery cannot join the turn. A run * represents one disposable activation, so it has no cold-resume operation; * resuming a settled child goes through {@link SubagentProvider.resume}. */ diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 1b8f2e4cbf..51e97f10d1 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -285,6 +285,13 @@ export function apply(ctx: Context, config: Config): void { if (control === undefined) { throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-subagent-control and @deepseek-ai/dsh-tool-tasks') } + // The schema above tells the model to follow up with + // `send_message`; starting a durable child the model cannot + // continue would make that advertisement false. Sibling load order + // is undetermined at mount, so the check lives at the operation. + if (ctx.tools.get('send_message') === undefined) { + throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-tool-subagent-control (the advertised send_message tool is not registered)') + } // The control service owns the durable child id, descriptor // snapshot, Task registration, and settle-then-dispose ordering; a // synchronous validation failure rejects the call with no Task. diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 41b383bf9e..9c3ddb461f 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -17,6 +17,7 @@ import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import SubagentControlService from '@deepseek-ai/dsh-subagent-control' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' +import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' @@ -825,7 +826,7 @@ describe('dsh-tool-subagent continuable background mode', () => { }) /** Boot the real continuable stack: loop, persistence, spawn, tasks, control. */ - async function continuableSetup() { + async function continuableSetup(options: { controlTool?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-')) @@ -837,6 +838,7 @@ describe('dsh-tool-subagent continuable background mode', () => { await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) await ctx.plugin(SubagentControlService) + if (options.controlTool !== false) await ctx.plugin(ToolSubagentControl) await ctx.plugin(tool, { provider: 'spawn' }) ctx.llm.registerAdapter(['mock'], new MockAdapter([ textResponse('continuable answer'), @@ -886,6 +888,21 @@ describe('dsh-tool-subagent continuable background mode', () => { expect(result.isError).toBe(true) expect(text(result)).toContain('load @deepseek-ai/dsh-subagent-control') }) + + it('fails loud when the advertised send_message tool is not registered', async () => { + // The schema tells the model to follow up with send_message; starting a + // durable child the model cannot continue would make that false. + const { ctx, parent } = await continuableSetup({ controlTool: false }) + const result = await callSubagent( + ctx, + { description: 'd', prompt: 'p', run_in_background: true }, + { agent: parent }, + ) + expect(result.isError).toBe(true) + expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control') + // Nothing was started: no Task exists for the parent. + expect(ctx.tasks.list(parent)).toEqual([]) + }) }) describe('background preflight failure (no orphaned child, by construction)', () => { From 4eda48d002f3d9cab151aa5de8a2e2c09a41dbf8 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 18:14:37 +0800 Subject: [PATCH 030/114] fix: address codex review round 2 - Wire the control service and send_message tool into every shipped composition with a resumable provider and background enabled (headless-agent, tui-agent, and the SDK helper's subagent feature base resources); jsonrpc-agent disables background and is unchanged. - Resolve the send_message availability check in the CALLER's tool scope so a restriction that removes the follow-up tool from one agent also blocks that agent's continuable start. - Control-service disposal now cancels live activations and awaits producer settlement instead of stranding them: TaskService keeps producer Tasks across a reload, so the disposing service aborts each activation-owned controller, resolves its terminal gate (the effect-scoped onTaskDone listener is already gone), and awaits done. A new test kills a mid-start activation through HMR disposal. --- apps/cli/composition.md | 6 +++ apps/cli/config/base.cordis.yml | 9 ++++ apps/cli/package.json | 2 + docs/cordis-catalog/services.md | 2 +- examples/headless-agent/composition.md | 6 +++ examples/headless-agent/cordis.yml | 9 ++++ .../sdk/helper/src/features/builtin/index.ts | 8 +++- .../subagent/subagent-control/src/index.ts | 30 ++++++++++--- .../tests/subagent-control.spec.ts | 45 +++++++++++++++++++ packages/subagent/tool-subagent/src/index.ts | 6 ++- .../tool-subagent/tests/tool-subagent.spec.ts | 16 +++++++ pnpm-lock.yaml | 6 +++ 12 files changed, 136 insertions(+), 9 deletions(-) diff --git a/apps/cli/composition.md b/apps/cli/composition.md index c4deb098c4..76844279f6 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -94,6 +94,10 @@ flowchart LR cfg --> plugin_tui_subagent_spawn plugin_tui_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_tui_subagent_fork + plugin_tui_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"] + cfg --> plugin_tui_subagent_control + plugin_tui_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] + cfg --> plugin_tui_tool_subagent_control plugin_tui_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_tui_tool_subagent plugin_tui_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] @@ -185,6 +189,8 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `subagent-control` | `@deepseek-ai/dsh-subagent-control` | +| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index df7ed94258..4982a521a7 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -257,6 +257,15 @@ config: providerName: fork +# Continuable background children: the control service owns durable child ids +# and Task-backed activations; the control tool registers the one global +# `send_message` shared by both delegation tools. +- id: subagent-control + name: '@deepseek-ai/dsh-subagent-control' + +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/apps/cli/package.json b/apps/cli/package.json index 58368f04d9..4cfdf29725 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -101,6 +101,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-control": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", @@ -120,6 +121,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0e83259ea2..9028df3814 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1988,7 +1988,7 @@ sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMes Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/subagent/subagent-control/src/index.ts:152`](../../packages/subagent/subagent-control/src/index.ts) +Source: [`packages/subagent/subagent-control/src/index.ts:156`](../../packages/subagent/subagent-control/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 38774195e1..ecf343a264 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -37,6 +37,10 @@ flowchart LR cfg --> plugin_headless_subagent_spawn plugin_headless_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_headless_subagent_fork + plugin_headless_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"] + cfg --> plugin_headless_subagent_control + plugin_headless_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] + cfg --> plugin_headless_tool_subagent_control plugin_headless_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_headless_tool_subagent plugin_headless_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] @@ -70,6 +74,8 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `subagent-control` | `@deepseek-ai/dsh-subagent-control` | +| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 3a74bd4976..73673aee8d 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -86,6 +86,15 @@ config: providerName: fork +# Continuable background children: the control service owns durable child ids +# and Task-backed activations; the control tool registers the one global +# `send_message` shared by both delegation tools. +- id: subagent-control + name: '@deepseek-ai/dsh-subagent-control' + +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 09fe8eb5b7..31b4fc77c3 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -209,7 +209,13 @@ config: id: 'subagent', summary: 'Delegate work to child agents', mode: 'multiple', - baseResources: [{ kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }], + // The control pair rides every resumable in-process option: background + // delegation on spawn/fork is continuable and advertises send_message. + baseResources: [ + { kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }, + { kind: 'npm-cordis-config-entry', id: 'subagent-control', package: '@deepseek-ai/dsh-subagent-control' }, + { kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' }, + ], options: [ { id: 'spawn', diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts index fa1974eb22..b1aa00ab02 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent-control/src/index.ts @@ -86,6 +86,10 @@ interface ActiveActivation { taskId: TaskId | undefined /** Filled when the provider publishes; `undefined` while starting or resuming. */ run: SubagentRun | undefined + /** The activation-owned cancellation authority, created before any await. */ + readonly controller: AbortController + /** The producer's settlement (run disposed, outcome produced); assigned when the Task registers. */ + done: Promise | undefined /** Resolved by the completion listener when the Task's terminal snapshot is recorded. */ readonly terminal: PromiseWithResolvers } @@ -164,7 +168,21 @@ export class SubagentControlService extends Service { if (activation.taskId === snapshot.id) activation.terminal.resolve() } }) - ctx.effect(() => () => { this.activations.clear() }, 'subagentControl.activations()') + // TaskService deliberately keeps producer Tasks alive across a + // control-surface or producer reload, so this service's disposal must not + // strand the activations it can no longer route to: cancel each one and + // await producer settlement (run disposal) before releasing the map. The + // effect-scoped onTaskDone listener above is already gone by then, so + // terminal publication is resolved here instead of waiting forever. + ctx.effect(() => async () => { + const active = [...this.activations.values()] + this.activations.clear() + for (const activation of active) { + activation.controller.abort('subagent control service disposed') + activation.terminal.resolve() + } + await Promise.allSettled(active.map(activation => activation.done ?? Promise.resolve())) + }, 'subagentControl.activations()') } /** @@ -368,6 +386,8 @@ export class SubagentControlService extends Service { const activation: ActiveActivation = { taskId: undefined, run: undefined, + controller: new AbortController(), + done: undefined, terminal: Promise.withResolvers(), } this.activations.set(childId, activation) @@ -378,21 +398,21 @@ export class SubagentControlService extends Service { label, owner, run: (): TaskHooks => { - const controller = new AbortController() const done = (async (): Promise => { try { - const run = await begin(controller.signal) + const run = await begin(activation.controller.signal) activation.run = run return await settleRun(run) } catch (error: unknown) { // A pre-publication abort rejects only after the provider's // creation transaction rolled back to quiescence, so recording // `killed` here honors the settlement-after-rollback contract. - return controller.signal.aborted + return activation.controller.signal.aborted ? { status: 'killed' } : { status: 'failed', detail: String(error) } } })() + activation.done = done void Promise.allSettled([done, activation.terminal.promise]).then(() => { /* v8 ignore else -- service teardown clears the map while a producer is still settling. */ if (this.activations.get(childId) === activation) this.activations.delete(childId) @@ -401,7 +421,7 @@ export class SubagentControlService extends Service { cancel: (reason?: string) => { // Cancellation targets the whole activation: every message that // joined this turn shares the `killed` outcome. - controller.abort(reason ?? 'subagent activation killed') + activation.controller.abort(reason ?? 'subagent activation killed') }, done, // No readOutput: the child session owns intermediate detail. diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index c76cc5ad41..391b94a30a 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -482,6 +482,51 @@ describe('SubagentControlService.sendMessage', () => { }) }) +describe('service disposal with live activations', () => { + it('cancels and settles a starting activation on service disposal instead of stranding it', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-control-hmr-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(TaskService) + await ctx.plugin(ToolTasks, {}) + // A provider that stays pending until its signal aborts, so the activation + // is observably mid-start when the control service is disposed. + let sawAbort = false + ctx.subagents.registerProvider({ + name: 'pending', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: request => new Promise((_resolve, reject) => { + request.signal.addEventListener('abort', () => { + sawAbort = true + reject(new Error('startup aborted')) + }, { once: true }) + }), + resume: () => Promise.reject(new Error('unreachable')), + }) + const controlFiber = await ctx.plugin(SubagentControlService) + ctx.llm.registerAdapter(['mock'], new MockAdapter([])) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + + const control = ctx.get('subagentControl')! + const started = control.startContinuable({ + provider: 'pending', + label: 'will be interrupted', + request: { prompt: message('go'), parent }, + }) + // TaskService keeps the producer Task; the disposing control service must + // cancel its activation and await settlement rather than strand it. + await controlFiber.dispose() + expect(sawAbort).toBe(true) + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('killed') + }) +}) + describe('outcome mapping helpers', () => { it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => { const output = [{ type: 'text' as const, text: 'partial' }] diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 51e97f10d1..560e0cb20b 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -288,8 +288,10 @@ export function apply(ctx: Context, config: Config): void { // The schema above tells the model to follow up with // `send_message`; starting a durable child the model cannot // continue would make that advertisement false. Sibling load order - // is undetermined at mount, so the check lives at the operation. - if (ctx.tools.get('send_message') === undefined) { + // is undetermined at mount, so the check lives at the operation, + // and it resolves in the CALLER's scope so a restriction that + // removes send_message from this agent also blocks the start. + if (ctx.tools.get('send_message', parent) === undefined) { throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-tool-subagent-control (the advertised send_message tool is not registered)') } // The control service owns the durable child id, descriptor diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 9c3ddb461f..f631133970 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -903,6 +903,22 @@ describe('dsh-tool-subagent continuable background mode', () => { // Nothing was started: no Task exists for the parent. expect(ctx.tasks.list(parent)).toEqual([]) }) + + it('resolves send_message availability in the CALLER scope, not the global registry', async () => { + // A scoped restriction that keeps this delegation tool but removes + // send_message means this agent cannot execute the promised follow-up; + // the availability check must see the caller's surface. + const { ctx, parent } = await continuableSetup() + parent.ctx.tools.restrict({ deny: ['send_message'] }) + const result = await callSubagent( + ctx, + { description: 'd', prompt: 'p', run_in_background: true }, + { agent: parent }, + ) + expect(result.isError).toBe(true) + expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control') + expect(ctx.tasks.list(parent)).toEqual([]) + }) }) describe('background preflight failure (no orphaned child, by construction)', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 534f592f65..80d30aeae9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -384,6 +384,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent + '@deepseek-ai/dsh-subagent-control': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-control '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../packages/subagent/subagent-fork @@ -441,6 +444,9 @@ importers: '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent-control '@deepseek-ai/dsh-tool-tasks': specifier: workspace:^ version: link:../../packages/tasks/tool-tasks From 9e5ae0d12e8582910a81a7e2fc2fb481670d3082 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 18:47:19 +0800 Subject: [PATCH 031/114] fix: address codex review round 3 - Strict steer additionally requires an OPEN STEP: between steps the loop may be awaiting its continuation/turn-stop checkpoints, where pending steering was already folded and a terminal stop discards a later arrival. A message accepted during an open step is drained and recorded at that step's settlement before any terminal decision, so the acknowledged-then-discarded window is closed. New keyless test holds agent/turn-stop open and pins the rejection. - tool-subagent-control README: distinguish synchronous not-delivered errors from started-Task failures (unknown/foreign/descriptor-less ids settle the started Task as failed), and drop the claim that the completion notice carries the child's response. --- ...continuable-background-subagents.i18n.yaml | 4 +-- ...-07-21-continuable-background-subagents.md | 2 +- ...-21-continuable-background-subagents.zh.md | 2 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 13 +++++++++ .../tests/subagent-inprocess.spec.ts | 29 +++++++++++++++++++ .../subagent/tool-subagent-control/README.md | 4 +-- 7 files changed, 49 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 3b5eaed10d..6350526088 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-continuable-background-subagents.md: abb8a89bd6ec0fbe4a36e7f82c1356fb96b38390 -2026-07-21-continuable-background-subagents.zh.md: 30207dfd757eeada3e8ba961b67c79db3c98aad6 +2026-07-21-continuable-background-subagents.md: a23943a0226d2ef4eee27d7294d7a98a84c5f109 +2026-07-21-continuable-background-subagents.zh.md: e645cfb0a11c554a30a7ad092b612c5bac7d8dea diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index abb8a89bd6..a23943a022 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -49,7 +49,7 @@ For a continuable initial activation, the control service allocates the stable c Every continuable child turn is admitted through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the control service synchronously compares its association with `ctx.agents.get(childId)`. A registry Agent with no association, or a registry Agent different from the associated `run.localAgent`, is an ownership conflict: the control service fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary. -Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability with synchronous checks that share one frame with the `Agent.steer()` call: the child must be `running`, its turn must still be open in the log (status stays `running` through a closed turn's durability flush, where the loop strands drained steering), and no structured capture may have committed (its terminal stop makes the loop discard late steering). Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict check, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. +Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability with synchronous checks that share one frame with the `Agent.steer()` call: the child must be `running`, its turn must still be open in the log (status stays `running` through a closed turn's durability flush, where the loop strands drained steering), a step must be open (between steps the loop may sit at its continuation/turn-stop checkpoints, where steering was already folded and a terminal stop discards a later arrival), and no structured capture may have committed (its terminal stop makes the loop discard late steering). Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict check, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. The control service does not serialize two callers that race a stopped child through paths outside it, nor does it model a separate settling phase between result production and disposal. The synchronous association install before the producer's first await admits one activation per child in this process — a competing `sendMessage` during resume load observes the pending activation and fails explicitly — while a bypassing publication still loses at the Agent registry's same-session collision boundary. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 30207dfd75..e645cfb0a1 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -49,7 +49,7 @@ durable child Session 每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,控制服务会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:控制服务会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。 -系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 用与 `Agent.steer()` 调用共享同一同步帧的检查来实现该功能:child 必须处于 `running` 状态,其轮次在日志中必须仍然打开(已关闭轮次的持久化 flush 期间状态仍是 `running`,此时循环会丢弃排空的 steering 消息),且不得已有结构化捕获提交(其终止性 stop 会让循环丢弃迟到的 steering)。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 用与 `Agent.steer()` 调用共享同一同步帧的检查来实现该功能:child 必须处于 `running` 状态,其轮次在日志中必须仍然打开(已关闭轮次的持久化 flush 期间状态仍是 `running`,此时循环会丢弃排空的 steering 消息),必须有打开的 step(step 之间循环可能停在其 continuation/turn-stop 检查点上,此时 steering 已被折叠,终止性 stop 会丢弃之后到达的消息),且不得已有结构化捕获提交(其终止性 stop 会让循环丢弃迟到的 steering)。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index f0660b1a5b..526b237efa 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -30,7 +30,7 @@ The required request signal covers both startup and the live run. Before publica After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed. -Runs expose the strict `steer` capability: the synchronous checks and the `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. Delivery requires `AgentStatus.running`, an open turn in the child log (status stays `running` through a closed turn's durability flush, where the loop would strand the message), and no committed structured capture (whose terminal stop makes the loop discard late steering). The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. +Runs expose the strict `steer` capability: the synchronous checks and the `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. Delivery requires `AgentStatus.running`, an open turn in the child log (status stays `running` through a closed turn's durability flush, where the loop would strand the message), an open step (between steps the loop may sit at its continuation/turn-stop checkpoints, where steering was already folded and a terminal stop discards a later arrival; a message accepted during an open step is recorded at that step's settlement before any terminal decision), and no committed structured capture (whose terminal stop makes the loop discard late steering). The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. ## Spawn and fork inputs diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 176acb4d70..43357250cf 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -276,6 +276,19 @@ function driveTurn( if (lastBoundary?.type !== 'turn/start') { throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`) } + // Terminal turn-stops only run between steps: with no step open, the + // loop may be awaiting its continuation/turn-stop checkpoints, where + // pending steering was already folded and a terminal decision discards + // a later arrival. A message accepted during an OPEN step is instead + // drained and recorded at that step's settlement checkpoint before any + // terminal decision (cancellation remains the documented shared-outcome + // race). + const lastStep = child.session.events.findLast( + event => event.type === 'step/start' || event.type === 'step/end', + ) + if (lastStep?.type !== 'step/start') { + throw new Error(`subagent child "${childId}" is between steps; the message was not delivered`) + } // A committed structured capture makes the pending `agent/turn-stop` // checkpoint terminal, and the loop then discards late steering. The // capture is synchronously observable, so reject rather than diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 1de7329ecc..03202a6707 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -258,6 +258,35 @@ describe('startInProcessRun', () => { await run.dispose() }) + it('strict steer rejects the between-steps window where a terminal turn-stop discards steering', async () => { + // Hold `agent/turn-stop` open: the step has closed, pending steering was + // already folded into the continuation decision, and a terminal stop + // would discard a message arriving now — the exact window an + // acknowledged delivery would be a lie. + const { ctx, parent } = await setup([textResponse('quick')]) + let releaseStop: (() => void) | undefined + ctx.on('agent/turn-stop', (agent) => { + if (agent.session.header.parentSession === undefined || releaseStop !== undefined) return undefined + return new Promise((resolve) => { + releaseStop = () => { resolve(undefined) } + }) + }) + const run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + await new Promise((resolve) => { + const timer = setInterval(() => { + if (releaseStop !== undefined) { clearInterval(timer); resolve() } + }, 5) + }) + expect(child.status).toBe('running') + expect(() => { run.steer!([{ type: 'text', text: 'too late for this turn' }]) }) + .toThrow(/between steps; the message was not delivered/) + releaseStop!() + await run.result + expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) + await run.dispose() + }) + it('strict steer rejects the closed-turn flush window where the loop discards steering', async () => { // Hold the turn-end durability flush open: the turn has closed in the log // and status is still `running`, exactly the window where the loop would diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index c4d27e3694..7654012145 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -24,11 +24,11 @@ Prefix-stable; the schema does not change at runtime. #### What the model sees -`message delivered to running task ` when the message joined the running activation, or `message started task continuing subagent ` when it cold-resumed the child. Failures are errored results whose message states the message was not delivered (unknown or foreign child, ownership conflict, settlement race, no live-delivery capability). +`message delivered to running task ` when the message joined the running activation, or `message started task continuing subagent ` when it started a cold-resume activation. Synchronous routing failures — an ownership conflict, a lost steering race, no live-delivery capability — are errored results whose message states the message was not delivered. An absent activation always reports `started`: lookup runs inside that Task, so an unknown, foreign, or descriptor-less child surfaces as the started Task settling `failed` (read through `task_output`), not as an errored `send_message` result. #### Token effect -One short acknowledgement per call; the child's response enters parent history only when collected through `task_output` or injected by the task completion notice. +One short acknowledgement per call; the child's response enters parent history only when collected through `task_output` (the completion notice is a status line, never the response). #### KV Cache effect From bb8ea2be51a57bb4602dbb690c0cb0d29bb3bda8 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 11:27:57 +0800 Subject: [PATCH 032/114] fix(subagent): make strict steering atomic --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 2 +- ...-21-continuable-background-subagents.zh.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 7 +- docs/architecture.zh.md | 7 +- docs/cordis-catalog/events.md | 32 ++-- docs/core-data-structures/core.md | 10 ++ docs/event-producer-consumer.md | 32 ++-- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/pty-tools/session.jsonl | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/src/agent.ts | 18 ++- packages/core/agent/README.md | 1 + packages/core/agent/src/types.ts | 10 ++ .../subagent/subagent-control/src/index.ts | 10 +- .../tests/subagent-control.spec.ts | 129 ++++++++++++++- .../subagent/subagent-inprocess/src/index.ts | 24 +-- .../tests/structured.spec.ts | 31 ++-- .../tests/subagent-inprocess.spec.ts | 152 ++++++++++++++++-- .../subagent/subagent/tests/service.spec.ts | 41 ++++- 24 files changed, 436 insertions(+), 92 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 6350526088..26572951a6 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-continuable-background-subagents.md: a23943a0226d2ef4eee27d7294d7a98a84c5f109 -2026-07-21-continuable-background-subagents.zh.md: e645cfb0a11c554a30a7ad092b612c5bac7d8dea +2026-07-21-continuable-background-subagents.md: 287239a22c440eb4758a8dab5621406246a7e0b7 +2026-07-21-continuable-background-subagents.zh.md: 36b28581e1bf05144e9ffd5de136983eff8fdabc diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index a23943a022..287239a22c 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -49,7 +49,7 @@ For a continuable initial activation, the control service allocates the stable c Every continuable child turn is admitted through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the control service synchronously compares its association with `ctx.agents.get(childId)`. A registry Agent with no association, or a registry Agent different from the associated `run.localAgent`, is an ownership conflict: the control service fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary. -Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability with synchronous checks that share one frame with the `Agent.steer()` call: the child must be `running`, its turn must still be open in the log (status stays `running` through a closed turn's durability flush, where the loop strands drained steering), a step must be open (between steps the loop may sit at its continuation/turn-stop checkpoints, where steering was already folded and a terminal stop discards a later arrival), and no structured capture may have committed (its terminal stop makes the loop discard late steering). Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict check, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. +Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability with synchronous checks followed by the default Agent loop's optional atomic `trySteer()`: the child must be `running`, its turn and step must still be open in the log, the step's final steering drain must not have begun, and no structured capture may have committed. The loop closes `trySteer()` acceptance before draining and entering `agent/post-step`, so a terminal stop cannot discard an acknowledged message from that window. A loop without `trySteer()` cannot back strict in-process delivery. Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict operation, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. The control service does not serialize two callers that race a stopped child through paths outside it, nor does it model a separate settling phase between result production and disposal. The synchronous association install before the producer's first await admits one activation per child in this process — a competing `sendMessage` during resume load observes the pending activation and fails explicitly — while a bypassing publication still loses at the Agent registry's same-session collision boundary. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index e645cfb0a1..36b28581e1 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -49,7 +49,7 @@ durable child Session 每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,控制服务会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:控制服务会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。 -系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 用与 `Agent.steer()` 调用共享同一同步帧的检查来实现该功能:child 必须处于 `running` 状态,其轮次在日志中必须仍然打开(已关闭轮次的持久化 flush 期间状态仍是 `running`,此时循环会丢弃排空的 steering 消息),必须有打开的 step(step 之间循环可能停在其 continuation/turn-stop 检查点上,此时 steering 已被折叠,终止性 stop 会丢弃之后到达的消息),且不得已有结构化捕获提交(其终止性 stop 会让循环丢弃迟到的 steering)。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 先执行同步检查,再调用默认 Agent 循环所提供的可选原子操作 `trySteer()`,以实现该功能:child 必须处于 `running` 状态,其轮次和步骤在日志中必须仍然打开,该步骤最后一次排空 steering(中途引导)必须尚未开始,且不得已有结构化捕获提交。循环会在排空 steering 并进入 `agent/post-step` 前关闭 `trySteer()` 准入,使终止性 stop 无法丢弃在这个窗口中已确认接收的消息。不提供 `trySteer()` 的循环无法支撑严格的进程内消息投递。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering,因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格操作之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 49febba2a9..47486ee35f 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 0e78d7f9157e55ab1c5b6f518ef723e61237446e -architecture.zh.md: 27498c0d36ea54e6c952e0c1264b191d1448a554 +architecture.md: c5788ad33dc87e104dbdf0f420ac937af9ff2662 +architecture.zh.md: db98ead01d5bcb689a2cfd199eaae059763ad19e diff --git a/docs/architecture.md b/docs/architecture.md index 0e78d7f915..c5788ad33d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,6 +98,7 @@ forever: materialize changed runtime context as sourced 'user/message' snapshot the derived messages (the reconstruction boundary) 'step/start' + open strict-steering acceptance agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' @@ -106,7 +107,7 @@ forever: parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - drain accepted tool context and steering + close strict-steering acceptance, then drain accepted tool context and steering 'step/end' continue for tools or steering unless a result concluded the turn otherwise agent/turn-stopping -> drain -> continue only for steering @@ -121,7 +122,7 @@ idle inject: Each step assembles ordered stable system sections, cache-safe dynamic contexts, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Admission-time and active-turn `inject()` stage for the next step; post-tool `additionalContexts` settles after results. Steering shares that staging boundary and requests another step. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. +Admission-time and active-turn `inject()` stage for the next step; post-tool `additionalContexts` settles after results. Steering shares that staging boundary and requests another step. The default loop closes its optional `trySteer()` acceptance immediately before the final steering drain; ordinary `steer()` keeps its best-effort routing semantics. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize one retry turn between failed-step and turn close; cancellation wins. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). @@ -135,7 +136,7 @@ Turn and step events are turn-enclosed. Idle `user/message` and standalone `comp ### Agent Handles -`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, `steer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. `cancel()` and `whenIdle()` control lifecycle. Awaited disposal owns teardown. +`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, `steer()`, optional `trySteer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. The default loop's `trySteer()` atomically rejects after the current step's final steering drain begins, while ordinary `steer()` retains best-effort routing. `cancel()` and `whenIdle()` control lifecycle. Awaited disposal owns teardown. ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 27498c0d36..db98ead01d 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -98,6 +98,7 @@ forever: materialize changed runtime context as sourced 'user/message' snapshot the derived messages (the reconstruction boundary) 'step/start' + open strict-steering acceptance agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' @@ -106,7 +107,7 @@ forever: parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - drain accepted tool context and steering + close strict-steering acceptance, then drain accepted tool context and steering 'step/end' continue for tools or steering unless a result concluded the turn otherwise agent/turn-stopping -> drain -> continue only for steering @@ -121,7 +122,7 @@ idle inject: 每个步骤都会组装有序的稳定系统提示词片段、缓存安全的动态上下文、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用这一暂存边界,并请求再执行一个步骤。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 +接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用这一暂存边界,并请求再执行一个步骤。默认循环会在最后一次排空 steering 前立即关闭其可选 `trySteer()` 的准入;普通 `steer()` 保留尽力路由语义。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在失败步骤与轮次关闭之间授权一个重试轮次;取消优先。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 @@ -135,7 +136,7 @@ idle inject: ### Agent 句柄 -`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`,或使用 `followup()`、`steer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。`cancel()` 与 `whenIdle()` 控制生命周期。需等待完成的资源释放负责拆卸。 +`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`,或使用 `followup()`、`steer()`、可选的 `trySteer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。当前步骤开始最后一次排空 steering 后,默认循环的 `trySteer()` 会原子地拒绝调用,而普通 `steer()` 保留尽力路由语义。`cancel()` 与 `whenIdle()` 控制生命周期。需等待完成的资源释放负责拆卸。 ### Agent 作用域 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index fa8a961f19..f118c5ab44 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:274`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:283`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:447`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:457`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,7 +161,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) ### `agent/inbox/update` — emit @@ -181,7 +181,7 @@ A still-pending queued item changed content. The item id, placement, and positio Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -204,7 +204,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:370`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -228,7 +228,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:386`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:396`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -258,7 +258,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:405`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:415`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -280,7 +280,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:356`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -305,7 +305,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:434`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:444`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -325,7 +325,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:282`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -349,7 +349,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:373`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:383`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -375,7 +375,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:430`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 70c63d8a84..91c1f1fabb 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -672,6 +672,16 @@ interface Agent { */ steer(message: UserMessage): void + /** + * Atomically submit steering only while the current step still owns its final + * drain. Returns `false` without accepting the message during admission, + * between steps, or after the final per-step drain has begun. Cancellation or + * disposal may still discard previously accepted steering. + * @param message - identified steering content and its producer provenance. + * @returns whether the message entered the current step. + */ + trySteer?(message: UserMessage): boolean + /** * Append model-facing context without running the model — the * `next-step`/no-wakeup preset of {@link send}. Admission or an open turn diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ce1e0f1310..0b3e8ff6f2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:447`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:323`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:301`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:386`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:405`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:434`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:274`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:457`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:321`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:370`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:396`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:415`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:356`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:383`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:430`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | 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 43a87440a2..5c484484bb 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): void;\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 export type InboxActionResult = 'applied' | 'not-found';\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 delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: 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): void;\n trySteer?(message: UserMessage): boolean;\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 export type InboxActionResult = 'applied' | 'not-found';\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 delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: 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/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 2a060ef30b..70ddefa5ea 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"85750b5e-389a-4dfb-83e7-3341025692da"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-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 /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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":"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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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":1783957884564,"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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 route: \"steered\" | \"started\";\n taskId: 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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":1785460681625,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"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 b092257bbd..3d58a9067a 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2b9d695a-5ba1-4520-8130-d618bc1a4743"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-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 /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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":"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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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":1783957884701,"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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 route: \"steered\" | \"started\";\n taskId: 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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":1785460681788,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"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 487541609a..39d437b56c 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 /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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":"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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 route: \"steered\" | \"started\";\n taskId: 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 4da592774f..076ee192f6 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":"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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 1657f0faa2..b090b438e6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1583,7 +1583,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Agent', - declaration: '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): void;\n inject(message: UserMessage): void;\n}', + declaration: '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): void;\n trySteer?(message: UserMessage): boolean;\n inject(message: UserMessage): void;\n}', }, { name: 'AgentCancelCause', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 8fc9341623..98c0fae6bc 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -132,7 +132,6 @@ export class ReactLoopAgent implements Agent { private abort: AbortController | undefined /** Resolves when the current admission and turn exit. */ done: Promise = Promise.resolve() - /** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */ readonly scope: Scope /** The agent's scoped composition context ({@link Agent.ctx}). */ @@ -143,6 +142,8 @@ export class ReactLoopAgent implements Agent { /** Whether the session log is owed a matching turn end event. */ private turnOpen = false private stepOpen = false + /** Whether {@link trySteer} can still join the current step's final drain. */ + private strictSteeringOpen = false /** Whether this loop instance has appended its initial/resume request anchor. */ private requestHeaderLogged = false @@ -242,6 +243,16 @@ export class ReactLoopAgent implements Agent { }) } + /** Atomically steer only while the current step still owns its final drain. */ + trySteer(input: UserMessage): boolean { + if (!this.strictSteeringOpen) return false + this.send(input, { + target: 'next-step', + wakeup: true, + }) + return true + } + /** Append model-facing context without waking the driver. */ inject(input: UserMessage): void { this.send(input, { @@ -500,6 +511,7 @@ export class ReactLoopAgent implements Agent { case 'request-failed': { // step() reports request failures only after step/start commits // and before its own step/end, so the step is always open here. + this.strictSteeringOpen = false this.stepOpen = false this.session.append('step/end', { turn, step }) if (!signal.aborted) { @@ -535,6 +547,7 @@ export class ReactLoopAgent implements Agent { } catch (caught: unknown) { try { if (this.stepOpen) { + this.strictSteeringOpen = false this.stepOpen = false this.session.append('step/end', { turn, step }) } @@ -552,6 +565,7 @@ export class ReactLoopAgent implements Agent { // failure paths (step(), the request-failed branch, the catch), so the // finally owes only the turn boundary. this.acceptsNextStep = false + this.strictSteeringOpen = false try { if (this.turnOpen) { // Re-entrant turn/end listeners must route new input to a later turn. @@ -624,6 +638,7 @@ export class ReactLoopAgent implements Agent { session.append('step/start', { turn, step }) this.stepOpen = true + this.strictSteeringOpen = true signal.throwIfAborted() const { request, preparedCall } = await this.buildRequest( @@ -692,6 +707,7 @@ export class ReactLoopAgent implements Agent { // Tool results stay adjacent to their calls; input accepted during the // request enters the log only after the complete result batch. + this.strictSteeringOpen = false const steered = this.drainOutbox(turn) session.append('step/end', { turn, step }) this.stepOpen = false diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8799bc3664..6026c917c6 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -65,6 +65,7 @@ The handle every plugin programs against: - `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`. - `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it. +- `agent.trySteer?(input)` — an optional strict-steering capability implemented by the default loop. It atomically submits an identified message only while the current step still owns its final drain, returning `false` without accepting input during admission, between steps, or after that drain begins; cancellation and disposal can still discard accepted steering. - `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. - `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement. - `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 525653776f..1acf8c5ae5 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -236,6 +236,16 @@ export interface Agent { */ steer(message: UserMessage): void + /** + * Atomically submit steering only while the current step still owns its final + * drain. Returns `false` without accepting the message during admission, + * between steps, or after the final per-step drain has begun. Cancellation or + * disposal may still discard previously accepted steering. + * @param message - identified steering content and its producer provenance. + * @returns whether the message entered the current step. + */ + trySteer?(message: UserMessage): boolean + /** * Append model-facing context without running the model — the * `next-step`/no-wakeup preset of {@link send}. Admission or an open turn diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts index b1aa00ab02..f8c161bcb8 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent-control/src/index.ts @@ -181,7 +181,12 @@ export class SubagentControlService extends Service { activation.controller.abort('subagent control service disposed') activation.terminal.resolve() } - await Promise.allSettled(active.map(activation => activation.done ?? Promise.resolve())) + await Promise.allSettled(active.map((activation) => { + /* v8 ignore next 2 -- TaskService invokes `run` synchronously before `start` returns; + * every retained activation has `done`, while registration failure removes it. */ + if (activation.done === undefined) return Promise.resolve() + return activation.done + })) }, 'subagentControl.activations()') } @@ -354,7 +359,8 @@ export class SubagentControlService extends Service { const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0)) if (descriptor === undefined) { throw new SubagentControlError( - `subagent "${childId}" has no supported continuation descriptor`, + `subagent "${childId}" has no supported continuation state and cannot be resumed; ` + + 'do not retry send_message with this id', 'NOT_RESUMABLE', ) } diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index 391b94a30a..97adf790a6 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -89,6 +89,20 @@ async function waitTerminal(ctx: Context, taskId: TaskId, parent: Agent) { return ctx.tasks.wait(taskId, 5_000, parent) } +async function waitPublishedRun(ctx: Context, childId: SessionId): Promise { + const control = ctx.subagentControl as unknown as { + activations: Map + } + await new Promise((resolve) => { + const timer = setInterval(() => { + if (control.activations.get(childId)?.run !== undefined) { + clearInterval(timer) + resolve() + } + }, 5) + }) +} + function message(text: string) { return [{ type: 'text' as const, text }] } @@ -145,6 +159,20 @@ describe('SubagentControlService.startContinuable', () => { expect(ctx.tasks.list(parent)).toEqual([]) }) + it('rolls back the activation when Task preflight throws', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + const realStart = ctx.tasks.start.bind(ctx.tasks) + ctx.tasks.start = () => { throw new Error('task preflight failed') } + try { + expect(() => ctx.subagentControl.startContinuable(startSpec(parent))) + .toThrow('task preflight failed') + } finally { + ctx.tasks.start = realStart + } + const control = ctx.subagentControl as unknown as { activations: Map } + expect(control.activations.size).toBe(0) + }) + it('rejects a non-JSON descriptor input synchronously with no Task', async () => { const { ctx, parent } = await setup([textResponse('unused')]) const spec = startSpec(parent) @@ -195,6 +223,85 @@ describe('SubagentControlService.startContinuable', () => { }) describe('SubagentControlService.sendMessage', () => { + it('omits undeclared model selectors and rejects a provider without live delivery', async () => { + const { ctx } = await setup([]) + const result = Promise.withResolvers<{ + output: { type: 'text'; text: string }[] + stopReason: 'completed' + }>() + let descriptor: SessionEvent<'subagent/descriptor'>['data'] | undefined + ctx.subagents.registerProvider({ + name: 'no-steer', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async (request) => { + descriptor = request.continuation?.descriptor + return { + id: request.continuation!.sessionId, + localAgent: undefined, + result: result.promise, + async dispose() {}, + } + }, + resume: async () => { throw new Error('not used') }, + }) + const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}) + const started = ctx.subagentControl.startContinuable(startSpec(parent, 'no-steer')) + await waitPublishedRun(ctx, started.childId) + + expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) + expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('join'))) + .toThrow(/provider does not accept live delivery/) + + let terminalDeliveryError: unknown + ctx.tasks.onTaskDone((snapshot) => { + if (snapshot.id !== started.taskId) return + try { + ctx.subagentControl.sendMessage(parent, started.childId, message('after terminal')) + } catch (error: unknown) { + terminalDeliveryError = error + } + }) + result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) + await waitTerminal(ctx, started.taskId, parent) + expect(String(terminalDeliveryError)).toContain('is completed') + }) + + it('rejects a registry agent different from the associated run agent', async () => { + const { ctx, parent } = await setup([]) + const result = Promise.withResolvers<{ + output: { type: 'text'; text: string }[] + stopReason: 'completed' + }>() + ctx.subagents.registerProvider({ + name: 'mismatched-local', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async (request) => { + const childId = request.continuation!.sessionId + const handle = await ctx.agents.create({ + sessionId: childId, + meta: { parentSession: request.parent.id }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + return { + id: childId, + localAgent: {} as Agent, + result: result.promise, + dispose: () => handle.dispose(), + } + }, + resume: async () => { throw new Error('not used') }, + }) + const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local')) + await waitPublishedRun(ctx, started.childId) + + expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('join'))) + .toThrow(/registry agent is not the associated activation's agent/) + result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) + await waitTerminal(ctx, started.taskId, parent) + }) + it('steers a running activation into the existing Task without creating a second Task', async () => { // Hold the child's first model call open so the child is observably // running when the message arrives; the steered content then drives a @@ -358,7 +465,23 @@ describe('SubagentControlService.sendMessage', () => { const attempt = ctx.subagentControl.sendMessage(parent, SessionId('plain-child'), message('continue?')) const snapshot = await waitTerminal(ctx, attempt.taskId, parent) expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('continuation descriptor') + expect(snapshot.detail).toContain( + 'has no supported continuation state and cannot be resumed; do not retry send_message with this id', + ) + }) + + it('derives fallback and bounded labels for resumed activations', async () => { + const { ctx, parent } = await setup([]) + const blank = ctx.subagentControl.sendMessage(parent, SessionId('blank-child'), message(' ')) + const longText = 'x'.repeat(100) + const long = ctx.subagentControl.sendMessage(parent, SessionId('long-child'), message(longText)) + + expect(ctx.tasks.get(blank.taskId, parent).label).toBe('subagent follow-up') + expect(ctx.tasks.get(long.taskId, parent).label).toBe(`${'x'.repeat(79)}…`) + await Promise.all([ + waitTerminal(ctx, blank.taskId, parent), + waitTerminal(ctx, long.taskId, parent), + ]) }) it('rejects delivery to a live agent outside control-service ownership', async () => { @@ -491,7 +614,7 @@ describe('service disposal with live activations', () => { await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(TaskService) + await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) // A provider that stays pending until its signal aborts, so the activation // is observably mid-start when the control service is disposed. @@ -518,7 +641,7 @@ describe('service disposal with live activations', () => { label: 'will be interrupted', request: { prompt: message('go'), parent }, }) - // TaskService keeps the producer Task; the disposing control service must + // LocalTaskService keeps the producer Task; the disposing control service must // cancel its activation and await settlement rather than strand it. await controlFiber.dispose() expect(sawAbort).toBe(true) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 43357250cf..a71ee4a05b 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -276,10 +276,10 @@ function driveTurn( if (lastBoundary?.type !== 'turn/start') { throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`) } - // Terminal turn-stops only run between steps: with no step open, the - // loop may be awaiting its continuation/turn-stop checkpoints, where - // pending steering was already folded and a terminal decision discards - // a later arrival. A message accepted during an OPEN step is instead + // Turn settlement only runs between steps: with no step open, the loop + // may be awaiting its continuation/turn-stopping checkpoint, where + // pending steering was already folded and a later arrival would miss + // this turn. A message accepted during an OPEN step is instead // drained and recorded at that step's settlement checkpoint before any // terminal decision (cancellation remains the documented shared-outcome // race). @@ -289,14 +289,20 @@ function driveTurn( if (lastStep?.type !== 'step/start') { throw new Error(`subagent child "${childId}" is between steps; the message was not delivered`) } - // A committed structured capture makes the pending `agent/turn-stop` - // checkpoint terminal, and the loop then discards late steering. The - // capture is synchronously observable, so reject rather than - // acknowledge a message the run is about to drop. + // A committed structured capture makes the pending step conclusion + // terminal. The capture is synchronously observable, so reject rather + // than acknowledge a message the run is about to drop. if (structured?.captured() !== undefined) { throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`) } - child.steer(createUserMessage({ content, source: { kind: 'user' } })) + // The atomic Agent operation closes before the final drain, so this + // cannot acknowledge content that the current step will not record. + if (child.trySteer === undefined) { + throw new Error(`subagent child "${childId}" agent does not support strict steering; the message was not delivered`) + } + if (!child.trySteer(createUserMessage({ content, source: { kind: 'user' } }))) { + throw new Error(`subagent child "${childId}" passed its steering checkpoint; the message was not delivered`) + } }, } } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 62ef25e095..3ed95d159d 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -121,28 +121,25 @@ describe('in-process structured output', () => { }) it('strict steer rejects delivery once the structured result is captured', async () => { - // Hold the capture's tool result open so the child is observably running - // with a committed capture: the pending agent/turn-stop checkpoint is - // terminal, and the loop would DISCARD a steering message, so an - // acknowledged delivery here would be a lie. - let releaseResult: (() => void) | undefined const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), ]) - ctx.on('agent/post-step', (agent) => { - if (agent.session.header.parentSession === undefined || releaseResult !== undefined) return - return new Promise((resolve) => { releaseResult = resolve }) + let run: Awaited> | undefined + let rejected: unknown + ctx.on('session/event', (session, event) => { + if (session.header.parentSession === undefined || run === undefined + || event.type !== 'tool/result' || rejected !== undefined) return + try { + run.steer?.([{ type: 'text', text: 'one more thing' }]) + } catch (error: unknown) { + rejected = error + } }) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) - await new Promise((resolve) => { - const timer = setInterval(() => { - if (releaseResult !== undefined) { clearInterval(timer); resolve() } - }, 5) - }) - expect(() => { run.steer!([{ type: 'text', text: 'one more thing' }]) }) - .toThrow(/already reported its structured result; the message was not delivered/) - releaseResult!() + run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result + expect(rejected).toBeInstanceOf(Error) + expect((rejected as Error).message) + .toMatch(/already reported its structured result; the message was not delivered/) expect(result.structured).toEqual({ answer: 7 }) await run.dispose() }) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 03202a6707..aaa4ac82d4 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -2,16 +2,16 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' -import { SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import InvariantService from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' -import SubagentService from '@deepseek-ai/dsh-subagent' +import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { startInProcessRun } from '../src/index.ts' +import { resumeInProcessRun, startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -186,6 +186,61 @@ describe('startInProcessRun', () => { expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) + it('rejects an already-aborted resume before publication', async () => { + const { parent } = await setup([]) + const controller = new AbortController() + controller.abort('too late') + await expect(resumeInProcessRun({ + sessionId: SessionId('resumed-child'), + prompt: [{ type: 'text', text: 'continue' }], + parent, + signal: controller.signal, + descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, + })).rejects.toThrow('aborted before child publication') + }) + + it('resumes without inventing undeclared agent model options', async () => { + const childId = SessionId('resumed-child') + const child = { + id: childId, + options: {}, + session: new Session(childId), + status: 'idle', + acceptsNextStep: false, + ctx: new Context(), + send(): void {}, + reserveTurnAdmission: () => undefined, + updateInbox: () => 'not-found', + followup(): void {}, + steer(): void {}, + inject(): void {}, + cancel(): void {}, + whenIdle: () => Promise.resolve(), + } as Agent + let resumedOptions: unknown + const parent = { + ctx: { + agents: { + resume: (options: { agentOptions: unknown }) => { + resumedOptions = options.agentOptions + return Promise.resolve({ agent: child, dispose: () => Promise.resolve() }) + }, + }, + }, + } as unknown as Agent + + const run = await resumeInProcessRun({ + sessionId: childId, + prompt: [{ type: 'text', text: 'continue' }], + parent, + signal: new AbortController().signal, + descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, + }) + expect(resumedOptions).toEqual({}) + await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + await run.dispose() + }) + it('uses the request signal after publication and dispose as cancellation paths', async () => { const { parent, adapter } = await setup(['hang', 'hang']) const controller = new AbortController() @@ -258,14 +313,12 @@ describe('startInProcessRun', () => { await run.dispose() }) - it('strict steer rejects the between-steps window where a terminal turn-stop discards steering', async () => { - // Hold `agent/turn-stop` open: the step has closed, pending steering was - // already folded into the continuation decision, and a terminal stop - // would discard a message arriving now — the exact window an - // acknowledged delivery would be a lie. + it('strict steer rejects the between-steps turn-stopping window', async () => { + // Hold `agent/turn-stopping` open after the step closed and pending + // steering was folded into the continuation decision. const { ctx, parent } = await setup([textResponse('quick')]) let releaseStop: (() => void) | undefined - ctx.on('agent/turn-stop', (agent) => { + ctx.on('agent/turn-stopping', (agent) => { if (agent.session.header.parentSession === undefined || releaseStop !== undefined) return undefined return new Promise((resolve) => { releaseStop = () => { resolve(undefined) } @@ -287,6 +340,87 @@ describe('startInProcessRun', () => { await run.dispose() }) + it('strict steer rejects reentrant delivery after the final drain begins', async () => { + const { ctx, parent } = await setup([textResponse('quick')]) + let run: Awaited> | undefined + let seeded = false + let rejected: unknown + ctx.on('session/event', (session, event) => { + if (session.header.parentSession === undefined || run === undefined) return + if (event.type === 'assistant/chunk' && !seeded) { + seeded = true + run.steer?.([{ type: 'text', text: 'accepted before the drain' }]) + } else if (event.type === 'steering/message' && rejected === undefined) { + try { + run.steer?.([{ type: 'text', text: 'after the drain began' }]) + } catch (error: unknown) { + rejected = error + } + } + }) + + run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + await run.result + expect(seeded).toBe(true) + expect(rejected).toBeInstanceOf(Error) + expect((rejected as Error).message) + .toMatch(/passed its steering checkpoint; the message was not delivered/) + expect(child.session.events.filter(event => event.type === 'steering/message')).toHaveLength(1) + await run.dispose() + }) + + it('strict steer rejects an Agent implementation without atomic steering', async () => { + const childId = SessionId('custom-loop-child') + const childSession = new Session(childId) + childSession.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + childSession.append('step/start', { turn: 1, step: 1 }) + const idle = Promise.withResolvers() + const child = { + id: childId, + options: {}, + session: childSession, + status: 'running', + acceptsNextStep: false, + ctx: new Context(), + send(): void {}, + reserveTurnAdmission: () => undefined, + updateInbox: () => 'not-found', + followup(): void {}, + steer(): void {}, + inject(): void {}, + cancel(): void {}, + whenIdle: () => idle.promise, + } as Agent + const parentId = SessionId('custom-loop-parent') + const parent = { + id: parentId, + options: {}, + session: new Session(parentId), + ctx: { + get: () => undefined, + agents: { + create: () => Promise.resolve({ + agent: child, + dispose: () => { + idle.resolve(undefined) + return Promise.resolve() + }, + }), + }, + }, + } as unknown as Agent + + const run = await startInProcessRun(request(parent), {}) + expect(() => { run.steer!([{ type: 'text', text: 'unsupported strict delivery' }]) }) + .toThrow(/does not support strict steering; the message was not delivered/) + await run.dispose() + await run.result + }) + it('strict steer rejects the closed-turn flush window where the loop discards steering', async () => { // Hold the turn-end durability flush open: the turn has closed in the log // and status is still `running`, exactly the window where the loop would diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 5b9eeaaa0f..1c5889bdc2 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -5,6 +5,9 @@ import { type Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' import { carrierKeyOf } from '@deepseek-ai/dsh-scope' import SubagentService, { + foldSubagentDescriptor, + snapshotSubagentDescriptor, + SUBAGENT_DESCRIPTOR_VERSION, SubagentError, assertSubagentMaxDepth, type SubagentCapabilities, @@ -13,7 +16,7 @@ import SubagentService, { type SubagentRun, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' function fakeParent(id = 'parent-1'): Agent { return { id: SessionId(id) } as unknown as Agent @@ -99,6 +102,28 @@ describe('SubagentService', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) }) + it('rejects continuable start and resume when the provider has no resume capability', async () => { + const { subagents } = await service() + subagents.registerProvider(new StubProvider('one-shot')) + const descriptor = snapshotSubagentDescriptor({ provider: 'one-shot' }) + const sessionId = SessionId('continuable-child') + const parent = fakeParent() + const signal = new AbortController().signal + + await expect(subagents.start('one-shot', baseRequest({ + parent, + signal, + continuation: { sessionId, descriptor }, + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) + await expect(subagents.resume('one-shot', { + sessionId, + prompt: [{ type: 'text', text: 'continue' }], + parent, + signal, + descriptor, + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) + }) + it.each([ ['outputSchema', { outputSchema: { type: 'object', properties: {} } }], ['depthLimit', { maxDepth: 1 }], @@ -247,3 +272,17 @@ describe('SubagentService', () => { expect(error.code).toBe('NO_PROVIDER') }) }) + +describe('subagent descriptors', () => { + it('omits absent model selectors and rejects unsupported versions', () => { + expect(snapshotSubagentDescriptor({ provider: 'spawn' })).toEqual({ + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + }) + const unsupported = { + type: 'subagent/descriptor', + data: { version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }, + } as unknown as SessionEvent<'subagent/descriptor'> + expect(foldSubagentDescriptor([unsupported])).toBeUndefined() + }) +}) From 1ab3cbf673b51f1634dd4dc01b48ade7cbc75ed6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 12:39:07 +0800 Subject: [PATCH 033/114] fix(subagent): harden continuable persistence --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 10 +- ...-21-continuable-background-subagents.zh.md | 10 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/subagent.md | 8 +- ...ent-durability-failure.cordis.snapshot.yml | 45 ++++++++ .../subagent-durability-failure.cordis.yml | 10 ++ examples/acp-agent/tests/acp.snapshot.ts | 16 ++- .../fixtures/subagent-durability-failure.ts | 14 +++ .../subagent-continuable/session.jsonl | 2 +- knip.json | 1 + packages/subagent/subagent-control/README.md | 2 +- .../subagent/subagent-control/src/index.ts | 9 +- .../tests/subagent-control.spec.ts | 19 +++- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 9 +- .../subagent/subagent-inprocess/README.zh.md | 9 +- .../subagent/subagent-inprocess/src/index.ts | 34 +++++- .../tests/subagent-inprocess.spec.ts | 82 +++++++++++++- packages/subagent/subagent/README.md | 4 +- packages/subagent/subagent/src/descriptor.ts | 101 +++++++++++++++++- packages/subagent/subagent/src/types.ts | 6 +- .../subagent/subagent/tests/service.spec.ts | 67 ++++++++++-- 23 files changed, 412 insertions(+), 56 deletions(-) create mode 100644 examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml create mode 100644 examples/acp-agent/subagent-durability-failure.cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/subagent-durability-failure.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 26572951a6..a4bc2722c2 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-continuable-background-subagents.md: 287239a22c440eb4758a8dab5621406246a7e0b7 -2026-07-21-continuable-background-subagents.zh.md: 36b28581e1bf05144e9ffd5de136983eff8fdabc +2026-07-21-continuable-background-subagents.md: af7ef5c18c2af925e64b309d76e31ee079360b81 +2026-07-21-continuable-background-subagents.zh.md: 0c9e2e4d87e50ebb02cafe8f2333dca81ef8c5da diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index 287239a22c..af7ef5c18c 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -29,7 +29,7 @@ The low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agn ### Task and cancellation ownership -The initial background delegation asks the control service to start the child and register its Task. Task settlement awaits the result, calls `run.dispose()` (through the control service's `settleRun`), and only then records the `TaskOutcome`; `task_kill` aborts the active run, whose settlement path still disposes it. A terminal Task therefore leaves the durable child session but no live child Agent. +The initial background delegation asks the control service to start the child and register its Task. A continuable provider confirms the activation's final session state durable before fulfilling a successful run result. Task settlement awaits that result, calls `run.dispose()` (through the control service's `settleRun`), and only then records the `TaskOutcome`; `task_kill` aborts the active run, whose settlement path still disposes it. A terminal Task therefore leaves the durable child session but no live child Agent. A failed required durability checkpoint rejects the run with stable code `DURABILITY_FAILED` and the backend failure as its cause; the control service records a failed Task whose detail explains that the latest state was not confirmed persisted and may be unavailable or stale on resume. Every later turn creates another Task. Its producer resources cover only that activation, never the child session. It reaches one terminal status, has one result, and is never reopened. The exact live parent Agent remains the Task registry owner: disposing that instance cancels, awaits, and removes its Tasks. Task APIs authorize a caller whose session id matches that owner, but a same-id replacement does not become the notification or teardown target. This preserves the `settleRun()` contract and bounds Task-owned live children by concurrent work rather than historical session count. @@ -77,7 +77,7 @@ Cold resume cannot depend on an optional method of the old `SubagentRun`, becaus `SubagentControlService`'s resume path loads the known child session, folds its descriptor, authorizes the persisted `parentSession`, and runs inside the Task it creates. It passes a fully resolved request, including the Task-owned cancellation signal, to the low-level `SubagentService.resume(provider, request)`, whose only responsibility is capability-checked provider dispatch and the ordinary run lifecycle observation used by `start`. The selected `SubagentProvider.resume?()` owns transport-specific reconstruction (in-process: `parent.ctx.agents.resume` under the currently loaded parent scope) and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag exists. `SubagentControlService.sendMessage()` chooses between the associated run's `steer?()` operation and this cold-resume path. Neither the low-level service nor a provider enumerates durable children or associates Tasks. -The background tool validates and snapshots descriptor inputs before calling `TaskService.start()`. A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. Child creation, first-turn persistence, or descriptor persistence failure disposes any published run and settles the already-created Task as `failed`; the model observes that failure through the ordinary Task completion or `task_output` path. In-process spawn and fork reconstruct composition under the currently loaded parent scope. A fork resume loads the child's own persisted transcript, which already contains the completed-turn prefix captured at initial creation; it never forks the parent's newer history again. Resuming a parent does not eagerly resume its children. +The background tool validates and snapshots descriptor inputs before calling `TaskService.start()`. A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. In-process continuable providers perform a final session flush after the child becomes idle and before reading the result; this retries a failed loop checkpoint while the child is still live. If the final confirmation fails, the provider rejects instead of returning unconfirmed output, the control service disposes the run, and the already-created Task settles as `failed` with the durability diagnosis in its detail. Foreground one-shot runs retain the loop's best-effort checkpoint behavior. In-process spawn and fork reconstruct composition under the currently loaded parent scope. A fork resume loads the child's own persisted transcript, which already contains the completed-turn prefix captured at initial creation; it never forks the parent's newer history again. Resuming a parent does not eagerly resume its children. TODO (ACP continuation): persist the remote ACP session id as provider-specific descriptor data and implement `AcpProvider.resume?()` as spawn, initialize, `loadSession`, then prompt. The initial ACP run must verify `initialize.agentCapabilities.loadSession`, and every resumed process must use the same durable backend; replayed history from `loadSession` must not be collected as the new activation's output. Because ACP load support is negotiated per child rather than established solely by the provider method's presence, this follow-up must also define how a start result advertises child-specific continuation before ACP children enter the durable catalog. @@ -107,10 +107,10 @@ Task records and active-run associations are process-local. Persistence makes th ## Testing -- `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id; `task_kill` during a run or during cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task; cold follow-ups accumulate turns in one durable transcript with the declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once. +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id; `task_kill` during a run or during cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task; cold follow-ups accumulate turns in one durable transcript with the declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once. - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_message`, a one-shot provider keeps the plain task acknowledgement, and a resumable provider without the control service fails loud. -- The keyless ACP snapshot scenario `subagent-continuable` (examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, `task_output` collection, and a `send_message` follow-up whose started Task fails with the id unavailable. +- The keyless ACP snapshot scenario `subagent-continuable` (examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, a final durability-confirmation failure rendered through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. ## Consequences @@ -119,6 +119,6 @@ Task records and active-run associations are process-local. Persistence makes th - Driving a continuable child through the ordinary Agent API bypasses its Task association. The control service rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through `SubagentControlService.sendMessage()`. - The active-run association coordinates only one runtime. Concurrent resume from multiple processes is not serialized; that deployment requires a persistence-level lease or compare-and-set operation. - Human interaction requires the exact parent Agent instance to remain live because owner disposal cancels and removes its Tasks. It also requires an attached Task control surface. Standalone child interaction requires a future separation between Task access ownership and durable notification targeting. -- The background tool returns child and Task ids before child publication and descriptor durability. Startup failure, persistence failure, or process exit before the first child flush may leave an unmaterialized child id; by-id control reports it as unavailable and durable enumeration omits it rather than retroactively changing the tool result. +- The background tool returns child and Task ids before child publication and descriptor durability. Startup failure, a failed final durability confirmation, or process exit before the first child flush leaves the Task failed and may leave an unmaterialized or stale child id; by-id control reports missing state as unavailable rather than retroactively changing the tool acknowledgement. - Persisting explicit composition fields in the child log makes their lossless-JSON and compatibility contract part of resume. Later support for another composition input requires a deliberate descriptor-version change rather than implicitly persisting merge-extensible `AgentOptions` fields. - Task records and active-run associations are process-local even though child sessions are durable. Restart recovers the session, not in-flight work or its Task notification. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 36b28581e1..0c9e2e4d87 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -29,7 +29,7 @@ durable child Session ### Task 与取消的所有权 -初始后台委派请求控制服务启动 child 并注册其 Task。Task 结算流程等待结果,调用 `run.dispose()`(经由控制服务的 `settleRun`),然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。 +初始后台委派请求控制服务启动 child 并注册其 Task。可继续提供方只有在确认本次激活的最终会话状态已持久化后,才会返回成功的 run 结果。Task 结算流程等待该结果,调用 `run.dispose()`(经由控制服务的 `settleRun`),然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。必需的持久性检查点失败时,run 会以稳定错误码 `DURABILITY_FAILED` 拒绝,并将后端失败保留为失败原因;控制服务会记录失败的 Task,其详情说明最新状态未确认已持久化,因此恢复时可能不可用或已陈旧。 后续每个轮次都会创建另一个 Task。该轮 producer 持有的执行资源仅服务于这次激活,不属于 child 会话。它只会到达一次终态、只产生一个结果,也不会重新打开。Task 注册表中当前注册的那个存活 parent agent 实例仍是其 owner:dispose 该实例会取消、等待并移除其 Task。Task API 会授权 session id 与该 owner 匹配的调用方,但 id 相同的替代实例不会成为通知或资源清理目标。这一设计保留 `settleRun()` 契约,并使 Task 所拥有的存活 child 数量受并发工作量限制,而不是随历史会话数量增长。 @@ -77,7 +77,7 @@ durable child Session `SubagentControlService` 的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。 -后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。child 创建、首轮持久化或描述符持久化失败时,系统会 dispose 所有已发布的 run,并将已经创建的 Task 结算为 `failed`;模型通过普通 Task 完成通知或 `task_output` 路径观察该失败。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 +后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终会话 flush;此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,控制服务会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 TODO(ACP 继续执行):将远端 ACP session id 作为提供方专用描述符数据持久化,并实现 `AcpProvider.resume?()`,依次执行 spawn、initialize、`loadSession` 和 prompt。初始 ACP run 必须检查 `initialize.agentCapabilities.loadSession`,恢复后的每个进程必须使用同一个持久化后端;`loadSession` 回放的历史消息不得计入新激活的输出。由于 ACP 的加载支持是按 child 协商的,不能仅根据提供方是否存在该方法来确定,因此该后续工作还必须定义 start 结果如何声明单个 child 支持继续执行,之后才能将 ACP child 写入持久化目录。 @@ -107,10 +107,10 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 测试 -- `packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,而不创建第二个 Task;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,而不创建第二个 Task;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 -- 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、`task_output` 结果收集,以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 +- 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 ## 影响 @@ -119,6 +119,6 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 - 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。控制服务会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentControlService.sendMessage()` 提交用户输入。 - 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。 - 用户交互要求作为 owner 的那个精确 parent Agent 实例保持存活,因为 dispose owner 会取消并移除其 Task。用户交互还要求附加 Task 控制面。若要单独与 child 交互,后续必须将 Task 访问所有权与持久化通知目标分离。 -- 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、持久化失败,或进程在 child 首次 flush 之前退出,都可能留下 unmaterialized child id;按 id 的控制操作会报告该 id 不可用,持久化枚举也不会列出它,而不会追溯修改工具返回结果。 +- 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、最终持久性确认失败,或进程在 child 首次 flush 之前退出,都会使 Task 失败,并可能留下 unmaterialized 或陈旧的 child id;按 id 的控制操作会将缺失状态报告为不可用,而不会追溯修改工具确认消息。 - 将显式组合字段持久化到 child 日志后,其无损 JSON 与兼容性契约便成为恢复契约的一部分。后续如需支持其他组合配置输入,必须明确更改描述符版本,不能隐式持久化可通过声明合并扩展的 `AgentOptions` 字段。 - Task 记录和活跃 run 关联位于进程内,而 child 会话具有持久性。重启会恢复会话,但不会恢复进行中的工作或其 Task 通知。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 9028df3814..e73ce42c82 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1988,7 +1988,7 @@ sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMes Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/subagent/subagent-control/src/index.ts:156`](../../packages/subagent/subagent-control/src/index.ts) +Source: [`packages/subagent/subagent-control/src/index.ts:163`](../../packages/subagent/subagent-control/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index b5ec558351..b547305e8d 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -203,7 +203,7 @@ interface SubagentStopReasonMap { ## A live run: `SubagentRun` -`SubagentRun` is the consumer-owned handle for a ready child — one disposable activation, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. The optional strict `steer` method advertises live delivery by presence; cold resume deliberately does NOT live here (a disposed run cannot be reconstructed after restart) — it is `SubagentProvider.resume`. +`SubagentRun` is the consumer-owned handle for a ready child — one disposable activation, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. A completed continuable result additionally means the provider confirmed the activation's final state durable; a failed required checkpoint rejects. The optional strict `steer` method advertises live delivery by presence; cold resume deliberately does NOT live here (a disposed run cannot be reconstructed after restart) — it is `SubagentProvider.resume`. ```ts type-equiv /** @@ -228,8 +228,10 @@ interface SubagentRun { * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport * failure resolves with `stopReason: 'error'` so the consumer maps it to an - * `isError` tool result. Rejects only on an infrastructure fault the seam - * cannot represent as a stop reason. + * `isError` tool result. For a continuable activation, a completed result + * also means the provider confirmed the activation's final state durable. + * Rejects on an infrastructure fault the seam cannot represent as a stop + * reason, including a failed required durability checkpoint. */ readonly result: Promise /** diff --git a/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml b/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml new file mode 100644 index 0000000000..7ce0733e53 --- /dev/null +++ b/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml @@ -0,0 +1,45 @@ +# Keyless counterpart to subagent-durability-failure.cordis.yml: replace the +# live adapter with replay and fail the provider-owned final child checkpoint. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-durability-failure + name: './tests/fixtures/subagent-durability-failure.ts' diff --git a/examples/acp-agent/subagent-durability-failure.cordis.yml b/examples/acp-agent/subagent-durability-failure.cordis.yml new file mode 100644 index 0000000000..c033c323dc --- /dev/null +++ b/examples/acp-agent/subagent-durability-failure.cordis.yml @@ -0,0 +1,10 @@ +# Snapshot-only durability-failure overlay. The child turn's ordinary flush +# succeeds; the provider-owned final confirmation fails deterministically. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-durability-failure + name: './tests/fixtures/subagent-durability-failure.ts' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index aa4bad392c..c2e0328fab 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -41,6 +41,9 @@ const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) +const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( + new URL('../subagent-durability-failure.cordis.yml', import.meta.url), +) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) @@ -214,10 +217,15 @@ const SCENARIOS: Scenario[] = [ { name: 'subagent-fork', hasModelTurn: true, recorded: true }, { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, // Authored continuable-subagent transcript: a background delegation returns - // both the durable subagent id and its task id, task_output collects the - // child result after settlement, and send_message to an unknown subagent id - // starts a follow-up task that settles failed with the id unavailable. - { name: 'subagent-continuable', hasModelTurn: true, recorded: false }, + // both the durable subagent id and its task id, a failed final durability + // confirmation reaches task_output with its diagnosis, and send_message to + // an unknown subagent id starts a follow-up task that settles unavailable. + { + name: 'subagent-continuable', + hasModelTurn: true, + recorded: false, + configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG, + }, { name: 'subagent-depth-two-rejection', hasModelTurn: true, diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts new file mode 100644 index 0000000000..5d0137911d --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -0,0 +1,14 @@ +import type { Context } from 'cordis' + +export const name = 'subagent-durability-failure' + +/** Fail a continuable child's provider-owned final durability confirmation. */ +export function apply(ctx: Context): void { + const flushedTurnEnds = new WeakSet() + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined) return + if (session.events.at(-1)?.type !== 'turn/end') return + if (flushedTurnEnds.has(session)) throw new Error('snapshot disk full') + flushedTurnEnds.add(session) + }) +} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index e9e859d905..1b6e576b54 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -23,7 +23,7 @@ {"type":"assistant/chunk","seq":21,"time":1785517567391,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":22,"time":1785517567392,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fcea712-0e14-4f2d-909c-f7de70018053"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} {"type":"tool/call","seq":23,"time":1785517567392,"data":{"turn":1,"step":2,"callId":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}} -{"type":"tool/result","seq":24,"time":1785517567419,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_collect_1"},"content":[{"type":"tool-result","toolCallId":"call_collect_1","content":[{"type":"text","text":"CHILD_OK\n[status: completed]"}],"isError":false}],"role":"user","id":"ae79571a-fa78-4de0-9614-a10b5223230c"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"tool/result","seq":24,"time":1785517567419,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_collect_1"},"content":[{"type":"tool-result","toolCallId":"call_collect_1","content":[{"type":"text","text":"(no new output)\n[status: failed, subagent \"33333333-3333-4333-8333-333333333333\" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: snapshot disk full]"}],"isError":false}],"role":"user","id":"ae79571a-fa78-4de0-9614-a10b5223230c"}},"sourceEventSeqs":[23],"surfaceOp":"append"} {"type":"step/end","seq":25,"time":1785517567419,"data":{"turn":1,"step":2}} {"type":"step/start","seq":26,"time":1785517567425,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":27,"time":1789000000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/knip.json b/knip.json index 43102b0b0d..9f2c7db4a3 100644 --- a/knip.json +++ b/knip.json @@ -41,6 +41,7 @@ "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", + "acp-agent/tests/fixtures/subagent-durability-failure.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", diff --git a/packages/subagent/subagent-control/README.md b/packages/subagent/subagent-control/README.md index 002613d508..e65c6dcf1c 100644 --- a/packages/subagent/subagent-control/README.md +++ b/packages/subagent/subagent-control/README.md @@ -4,7 +4,7 @@ The continuable-subagent control service (`ctx.subagentControl`): the one orches ## Activation lifecycle -A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. +A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output. `sendMessage(parent, childId, message)` owns steer-or-resume routing. A running activation receives live delivery through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` (`started`). Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts index f8c161bcb8..ab0fab6d9d 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent-control/src/index.ts @@ -116,6 +116,13 @@ export function runOutcome(result: SubagentResult): TaskOutcome { } } +/** Render infrastructure failure detail without hiding a durability diagnosis. */ +function runFailureDetail(error: unknown): string { + return error instanceof HarnessError && error.code === 'DURABILITY_FAILED' + ? error.message + : String(error) +} + /** * Await the child result, dispose the run, then return its task outcome. Result * and disposal failures become `failed`; when both fail, both details survive. @@ -127,7 +134,7 @@ export async function settleRun(run: SubagentRun): Promise { try { outcome = runOutcome(await run.result) } catch (error: unknown) { - outcome = { status: 'failed', detail: String(error) } + outcome = { status: 'failed', detail: runFailureDetail(error) } } try { await run.dispose() diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index 97adf790a6..458e6a6960 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -16,7 +16,7 @@ import { TaskId } from '@deepseek-ai/dsh-tasks' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts' @@ -685,16 +685,29 @@ describe('outcome mapping helpers', () => { expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' }) expect(disposed).toBe(true) - const disposeFailed = await settleRun({ + const durabilityMessage = 'subagent "child-3" durability checkpoint failed; latest state unavailable: disk full' + const durabilityFailed = await settleRun({ id: SessionId('child-3'), localAgent: undefined, + result: Promise.reject(new HarnessError( + durabilityMessage, + 'DURABILITY_FAILED', + { cause: new Error('disk full') }, + )), + dispose: () => Promise.resolve(), + }) + expect(durabilityFailed).toEqual({ status: 'failed', detail: durabilityMessage }) + + const disposeFailed = await settleRun({ + id: SessionId('child-4'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'completed' }), dispose: () => Promise.reject(new Error('reap failed')), }) expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' }) const bothFailed = await settleRun({ - id: SessionId('child-4'), + id: SessionId('child-5'), localAgent: undefined, result: Promise.reject(new Error('result failed')), dispose: () => Promise.reject(new Error('reap failed')), diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index bd951115d2..2b5ea80435 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: 7587b6dfc44bef90756c9f2aba96d54872935fee -README.zh.md: 751e745c6c7a64831debd2df58ed8c3d7861f84d +README.md: eb5d973566f01c05b43f4f56eff746b7af93f60b +README.zh.md: 5be640f9b6da6402ece0e1d15997d9e2970a7d1c diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 526b237efa..eb5d973566 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -12,9 +12,10 @@ The driver follows this sequence: 1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header. 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id. -3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/pre-step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush. +3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns. +5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result. This final confirmation retries events retained after a failed turn checkpoint; if it still fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the backend failure as `cause`, and names the resumability risk in its message. Foreground runs keep the loop's best-effort checkpoint behavior. +6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records. The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. @@ -22,7 +23,7 @@ When the optional sandbox-policy or approval service is composed, the driver sna ## Cold resume -`resumeInProcessRun(request): Promise` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, abort handoff, and disposal follow the same contract as start. +`resumeInProcessRun(request): Promise` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, final durability confirmation, abort handoff, and disposal follow the same contract as a continuable start. ## Cancellation and ownership @@ -30,7 +31,7 @@ The required request signal covers both startup and the live run. Before publica After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed. -Runs expose the strict `steer` capability: the synchronous checks and the `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. Delivery requires `AgentStatus.running`, an open turn in the child log (status stays `running` through a closed turn's durability flush, where the loop would strand the message), an open step (between steps the loop may sit at its continuation/turn-stop checkpoints, where steering was already folded and a terminal stop discards a later arrival; a message accepted during an open step is recorded at that step's settlement before any terminal decision), and no committed structured capture (whose terminal stop makes the loop discard late steering). The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. +Runs expose the strict `steer` capability: the synchronous checks and the `Agent.trySteer()` call share one frame, so delivery joins the observed step or throws. Delivery requires `AgentStatus.running`, an open turn and step in the child log, no committed structured capture, and acceptance before that step's final drain begins. Admission, between-step processing such as `agent/turn-stopping`, and a closed turn's durability flush all reject delivery. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. ## Spawn and fork inputs diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 751e745c6c..5be640f9b6 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -12,9 +12,10 @@ 1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 -3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/pre-step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。 +3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的零步骤轮次。 +5. 对于可继续的启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`。这次最终确认会重试轮次检查点失败后保留的事件;若仍然失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,保留后端失败作为 `cause`,并在消息中指出可恢复性风险。前台运行仍采用循环的尽力而为检查点行为。 +6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 @@ -22,7 +23,7 @@ ## 冷恢复 -`resumeInProcessRun(request): Promise` 会在当前父级作用域下重建持久化的可继续子 agent:`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript(文本记录;fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、中止交接和 dispose 遵循与启动相同的契约。 +`resumeInProcessRun(request): Promise` 会在当前父级作用域下重建持久化的可继续子 agent:`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript(文本记录;fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、最终持久性确认、中止交接和 dispose 遵循与可继续启动相同的契约。 ## 取消与所有权 @@ -30,7 +31,7 @@ 兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。 -运行公开严格的 `steer` 功能:同步的 `AgentStatus.running` 检查与 `Agent.steer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的轮次,要么抛错。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback;否则会在运行结果读取后启动一个未被跟踪的轮次。 +运行公开严格的 `steer` 功能:同步检查与 `Agent.trySteer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的步骤,要么抛错。交付要求 `AgentStatus.running`、子 agent 日志中有开放的轮次和步骤、没有已提交的结构化捕获,并且在该步骤的最终 drain 开始前获接纳。提示词接纳、`agent/turn-stopping` 等步骤间处理,以及已关闭轮次的持久性 flush 都会拒绝交付。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback;否则会在运行结果读取后启动一个未被跟踪的轮次。 ## Spawn 与 fork 输入 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index a71ee4a05b..aaad34ccf8 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -11,8 +11,8 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' -import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' -import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent' +import { createUserMessage, errorChain, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentDescriptorData, SubagentResult, @@ -67,6 +67,9 @@ export interface InProcessRunOptions { readonly seed?: SessionEvent[] } +/** Whether one activation must prove its final state durable before success. */ +type Durability = 'best-effort' | 'required' + /** Error used when cancellation wins before the child publication boundary. */ function prePublicationAbort(): Error { return new Error('subagent request was aborted before child publication') @@ -168,7 +171,15 @@ export async function startInProcessRun( signal: request.signal, setup, }) - return driveTurn(handle, request.signal, request.prompt, childId, seedLength, structured) + return driveTurn( + handle, + request.signal, + request.prompt, + childId, + seedLength, + request.continuation === undefined ? 'best-effort' : 'required', + structured, + ) } /** @@ -203,14 +214,15 @@ export async function resumeInProcessRun(request: SubagentResumeRequest): Promis // The result boundary is this activation's own work: everything already in // the resumed transcript belongs to earlier turns. const resumePoint = handle.agent.session.events.length - return driveTurn(handle, request.signal, request.prompt, request.sessionId, resumePoint) + return driveTurn(handle, request.signal, request.prompt, request.sessionId, resumePoint, 'required') } /** * Drive one activation turn on a published child and wrap it as a run. The * caller has already created or resumed the agent; this owns the * signal-handoff race, the live abort listener, result collection past - * `boundary`, strict steering, and disposal. + * `boundary`, the continuable-run durability confirmation, strict steering, + * and disposal. */ function driveTurn( handle: AgentHandle, @@ -218,6 +230,7 @@ function driveTurn( prompt: ContentBlock[], childId: SessionId, boundary: number, + durability: Durability, structured?: StructuredAttachment, ): SubagentRun | Promise { const child = handle.agent @@ -238,6 +251,17 @@ function driveTurn( try { child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } })) await child.whenIdle() + if (durability === 'required') { + try { + await child.ctx.sessions.flush(child.session) + } catch (error: unknown) { + throw new SubagentError( + `subagent "${childId}" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`, + 'DURABILITY_FAILED', + { cause: error }, + ) + } + } return readResult( child, boundary, diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index aaa4ac82d4..bcec292d93 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -9,7 +9,7 @@ import InvariantService from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' -import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' +import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError } from '@deepseek-ai/dsh-subagent' import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { resumeInProcessRun, startInProcessRun } from '../src/index.ts' @@ -38,6 +38,22 @@ function request(parent: Agent, signal = new AbortController().signal) { return { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal } } +function continuableRequest(parent: Agent) { + const sessionId = SessionId('continuable-child') + return { + ...request(parent), + continuation: { + sessionId, + descriptor: { + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + agentProvider: 'mock', + agentModel: 'mock', + }, + }, + } +} + function text(blocks: readonly { type: string; text?: string }[]): string { return blocks.filter(block => block.type === 'text').map(block => block.text).join('') } @@ -56,6 +72,59 @@ describe('startInProcessRun', () => { expect(ctx.agents.get(run.id)).toBeUndefined() }) + it('requires a final durability checkpoint for a continuable child', async () => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + const failure = new Error('disk full') + let flushes = 0 + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined) return + flushes++ + throw failure + }) + + const run = await startInProcessRun(continuableRequest(parent), {}) + const caught: unknown = await run.result.catch((error: unknown) => error) + expect(caught).toBeInstanceOf(SubagentError) + const durabilityError = caught as SubagentError + expect(durabilityError.code).toBe('DURABILITY_FAILED') + expect(durabilityError.cause).toBe(failure) + expect(durabilityError.message).toContain( + 'the latest child state was not confirmed persisted and may be unavailable or stale on resume: disk full', + ) + expect(flushes).toBe(2) + await run.dispose() + }) + + it('completes a continuable child when the final checkpoint retries a transient flush failure', async () => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + let flushes = 0 + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined) return + flushes++ + if (flushes === 1) throw new Error('temporary append failure') + }) + + const run = await startInProcessRun(continuableRequest(parent), {}) + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(flushes).toBe(2) + await run.dispose() + }) + + it('keeps foreground runs best-effort when their turn checkpoint fails', async () => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + let flushes = 0 + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined) return + flushes++ + throw new Error('disk full') + }) + + const run = await startInProcessRun(request(parent), {}) + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(flushes).toBe(1) + await run.dispose() + }) + it('reports the message-turn outcome when a later non-message turn completes during flush', async () => { const { ctx, parent } = await setup([maxTokensResponse('partial answer')]) let injected = false @@ -201,13 +270,21 @@ describe('startInProcessRun', () => { it('resumes without inventing undeclared agent model options', async () => { const childId = SessionId('resumed-child') + let flushes = 0 const child = { id: childId, options: {}, session: new Session(childId), status: 'idle', acceptsNextStep: false, - ctx: new Context(), + ctx: { + sessions: { + flush: () => { + flushes++ + return Promise.resolve() + }, + }, + } as unknown as Context, send(): void {}, reserveTurnAdmission: () => undefined, updateInbox: () => 'not-found', @@ -238,6 +315,7 @@ describe('startInProcessRun', () => { }) expect(resumedOptions).toEqual({}) await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + expect(flushes).toBe(1) await run.dispose() }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index c7bf9af45a..68003363cd 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -49,7 +49,7 @@ Runtime features are optional methods whose presence is the capability check: `S ## The durable descriptor -The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before any Task exists, and `foldSubagentDescriptor()` recovers it from a loaded child log. The payload records the provider name, resolved child `agentOptions.provider`/`model`, and optional `persona`/`toolFilter` — explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. It omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. +The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before any Task exists, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Malformed current-version payloads fail before provider dispatch; unsupported versions make the child non-resumable. The payload records the provider name, resolved child `agentOptions.provider`/`model`, and optional `persona`/`toolFilter` — explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. It omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. ## Delegation depth @@ -61,7 +61,7 @@ The seam owns the depth vocabulary shared by implementations and consumers: the `provider.start(request): Promise` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. `provider.resume?(request)` shares the same contract for a resumed activation. -`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. +`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. For a continuable activation, a completed result also confirms that the provider made its final state durable; a failed required checkpoint rejects as infrastructure rather than publishing unconfirmed output. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the control-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index c837a696b8..00942ca448 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -71,6 +71,102 @@ export interface SubagentDescriptorInput { readonly toolFilter?: ToolRestriction } +const DESCRIPTOR_KEYS = new Set([ + 'version', + 'provider', + 'agentProvider', + 'agentModel', + 'persona', + 'toolFilter', +]) +const TOOL_FILTER_KEYS = new Set(['allow', 'deny']) + +/** Whether a persisted JSON value is an object record. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Reject fields outside one versioned record's declared schema. */ +function assertKnownKeys(value: Record, keys: ReadonlySet, path: string): void { + const unknown = Object.keys(value).find(key => !keys.has(key)) + if (unknown !== undefined) { + throw new Error(`persisted subagent descriptor ${path} has unknown field "${unknown}"`) + } +} + +/** Read one optional string field from a persisted descriptor record. */ +function optionalString(value: Record, key: string): string | undefined { + if (!Object.hasOwn(value, key)) return undefined + const field = value[key] + if (typeof field !== 'string') { + throw new Error(`persisted subagent descriptor ${key} must be a string`) + } + return field +} + +/** Read one optional string-array field from a persisted tool restriction. */ +function optionalStringArray(value: Record, key: string): string[] | undefined { + if (!Object.hasOwn(value, key)) return undefined + const field = value[key] + if (!Array.isArray(field)) { + throw new Error(`persisted subagent descriptor toolFilter.${key} must be an array of strings`) + } + const items: unknown[] = field + if (items.some(item => typeof item !== 'string')) { + throw new Error(`persisted subagent descriptor toolFilter.${key} must be an array of strings`) + } + return items as string[] +} + +/** Validate and reconstruct a persisted tool restriction. */ +function parseToolFilter(value: unknown): ToolRestriction { + if (!isRecord(value)) { + throw new Error('persisted subagent descriptor toolFilter must be an object') + } + assertKnownKeys(value, TOOL_FILTER_KEYS, 'toolFilter') + const allow = optionalStringArray(value, 'allow') + const deny = optionalStringArray(value, 'deny') + if (allow === undefined && deny === undefined) { + throw new Error('persisted subagent descriptor toolFilter must declare allow and/or deny') + } + return { + ...allow !== undefined ? { allow } : {}, + ...deny !== undefined ? { deny } : {}, + } +} + +/** Validate one persisted descriptor payload for the current runtime. */ +function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undefined { + if (!isRecord(value)) { + throw new Error('persisted subagent descriptor payload must be an object') + } + const version = value['version'] + if (typeof version !== 'number') { + throw new Error('persisted subagent descriptor version must be a number') + } + if (version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined + + assertKnownKeys(value, DESCRIPTOR_KEYS, 'payload') + const provider = value['provider'] + if (typeof provider !== 'string') { + throw new Error('persisted subagent descriptor provider must be a string') + } + const agentProvider = optionalString(value, 'agentProvider') + const agentModel = optionalString(value, 'agentModel') + const persona = optionalString(value, 'persona') + const toolFilter = Object.hasOwn(value, 'toolFilter') + ? parseToolFilter(value['toolFilter']) + : undefined + return { + version: SUBAGENT_DESCRIPTOR_VERSION, + provider, + ...agentProvider !== undefined ? { agentProvider } : {}, + ...agentModel !== undefined ? { agentModel } : {}, + ...persona !== undefined ? { persona } : {}, + ...toolFilter !== undefined ? { toolFilter } : {}, + } +} + /** * Validate and detach descriptor inputs into the durable payload, before any * Task or provider work begins — the same detached lossless-JSON boundary the @@ -105,12 +201,13 @@ export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): Suba * @returns the descriptor, or `undefined` when the log has none or its * version is not {@link SUBAGENT_DESCRIPTOR_VERSION} (the child is not * resumable by this runtime). + * @throws when a current-version persisted payload does not match its complete + * declared schema. */ export function foldSubagentDescriptor(events: readonly SessionEvent[]): SubagentDescriptorData | undefined { const event = events.find( (candidate): candidate is SessionEvent<'subagent/descriptor'> => candidate.type === 'subagent/descriptor', ) if (event === undefined) return undefined - if (event.data.version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined - return event.data + return parseSubagentDescriptor(event.data) } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 8cc88eb0c2..0b76723e26 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -207,8 +207,10 @@ export interface SubagentRun { * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport * failure resolves with `stopReason: 'error'` so the consumer maps it to an - * `isError` tool result. Rejects only on an infrastructure fault the seam - * cannot represent as a stop reason. + * `isError` tool result. For a continuable activation, a completed result + * also means the provider confirmed the activation's final state durable. + * Rejects on an infrastructure fault the seam cannot represent as a stop + * reason, including a failed required durability checkpoint. */ readonly result: Promise /** diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 1c5889bdc2..d04599a4d2 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -274,15 +274,68 @@ describe('SubagentService', () => { }) describe('subagent descriptors', () => { - it('omits absent model selectors and rejects unsupported versions', () => { - expect(snapshotSubagentDescriptor({ provider: 'spawn' })).toEqual({ + const event = (data: unknown): SessionEvent<'subagent/descriptor'> => ({ + type: 'subagent/descriptor', + data, + } as unknown as SessionEvent<'subagent/descriptor'>) + + it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => { + expect(foldSubagentDescriptor([])).toBeUndefined() + const minimal = snapshotSubagentDescriptor({ provider: 'spawn' }) + expect(minimal).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', }) - const unsupported = { - type: 'subagent/descriptor', - data: { version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }, - } as unknown as SessionEvent<'subagent/descriptor'> - expect(foldSubagentDescriptor([unsupported])).toBeUndefined() + expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal) + const complete = { + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + agentProvider: 'deepseek', + agentModel: 'chat', + persona: 'reviewer', + toolFilter: { allow: ['read'], deny: ['bash'] }, + } + expect(snapshotSubagentDescriptor({ + provider: complete.provider, + agentProvider: complete.agentProvider, + agentModel: complete.agentModel, + persona: complete.persona, + toolFilter: complete.toolFilter, + })).toEqual(complete) + expect(foldSubagentDescriptor([event(complete)])).toEqual(complete) + expect(foldSubagentDescriptor([ + event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { allow: ['read'] } }), + ])).toMatchObject({ toolFilter: { allow: ['read'] } }) + expect(foldSubagentDescriptor([ + event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { deny: ['bash'] } }), + ])).toMatchObject({ toolFilter: { deny: ['bash'] } }) + expect(foldSubagentDescriptor([ + event({ version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }), + ])).toBeUndefined() + expect(() => snapshotSubagentDescriptor({ + provider: 'spawn', + toolFilter: { deny: [Symbol('not-json')] as unknown as string[] }, + })).toThrow('not losslessly JSON-serializable') + }) + + it.each([ + ['string payload', 'invalid', 'payload must be an object'], + ['null payload', null, 'payload must be an object'], + ['array payload', [], 'payload must be an object'], + ['missing version', { provider: 'spawn' }, 'version must be a number'], + ['string version', { version: '1', provider: 'spawn' }, 'version must be a number'], + ['unknown payload field', { version: 1, provider: 'spawn', extra: true }, 'payload has unknown field "extra"'], + ['missing provider', { version: 1 }, 'provider must be a string'], + ['invalid provider', { version: 1, provider: 7 }, 'provider must be a string'], + ['invalid agent provider', { version: 1, provider: 'spawn', agentProvider: 7 }, 'agentProvider must be a string'], + ['invalid agent model', { version: 1, provider: 'spawn', agentModel: [] }, 'agentModel must be a string'], + ['invalid persona', { version: 1, provider: 'spawn', persona: {} }, 'persona must be a string'], + ['non-object tool filter', { version: 1, provider: 'spawn', toolFilter: [] }, 'toolFilter must be an object'], + ['unknown tool-filter field', { version: 1, provider: 'spawn', toolFilter: { except: ['bash'] } }, 'toolFilter has unknown field "except"'], + ['empty tool filter', { version: 1, provider: 'spawn', toolFilter: {} }, 'toolFilter must declare allow and/or deny'], + ['non-array allow list', { version: 1, provider: 'spawn', toolFilter: { allow: 'read' } }, 'toolFilter.allow must be an array of strings'], + ['non-string deny item', { version: 1, provider: 'spawn', toolFilter: { deny: [7] } }, 'toolFilter.deny must be an array of strings'], + ])('rejects a malformed persisted descriptor: %s', (_case, data, detail) => { + expect(() => foldSubagentDescriptor([event(data)])).toThrow(detail) }) }) From 189502e4ac65bffd86bcc42a69ee0fa6a936f605 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 13:18:35 +0800 Subject: [PATCH 034/114] fix(subagent): preserve follow-up provenance --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 8 +- ...-21-continuable-background-subagents.zh.md | 8 +- docs/cordis-catalog/services.md | 9 ++- docs/core-data-structures/subagent.md | 21 +++++- .../cordis/tool-cordis/src/api-catalog.ts | 8 +- packages/subagent/subagent-control/README.md | 2 +- .../subagent/subagent-control/src/index.ts | 38 ++++++++-- .../tests/subagent-control.spec.ts | 73 +++++++++++++------ .../subagent/subagent-inprocess/src/index.ts | 59 +++++++++------ .../tests/structured.spec.ts | 2 +- .../tests/subagent-inprocess.spec.ts | 18 +++-- .../tests/subagent-spawn.spec.ts | 2 +- packages/subagent/subagent/src/types.ts | 8 +- .../subagent/subagent/tests/service.spec.ts | 1 + .../subagent/tool-subagent-control/README.md | 2 +- .../tool-subagent-control/src/index.ts | 7 +- .../tests/tool-subagent-control.spec.ts | 13 +++- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 ++ 20 files changed, 202 insertions(+), 87 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index a4bc2722c2..10165b1d7a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-continuable-background-subagents.md: af7ef5c18c2af925e64b309d76e31ee079360b81 -2026-07-21-continuable-background-subagents.zh.md: 0c9e2e4d87e50ebb02cafe8f2333dca81ef8c5da +2026-07-21-continuable-background-subagents.md: 6552db82dc5cf1fabac8f18dd347cc8735f73587 +2026-07-21-continuable-background-subagents.zh.md: ed07abd2af34397d056cc022fc451e6397964acb diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index af7ef5c18c..6552db82dc 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -55,9 +55,9 @@ The control service does not serialize two callers that race a stopped child thr ### Model-facing `send_message` -The model receives one `send_message(subagent_id, message)` tool backed by `SubagentControlService.sendMessage()`. The control operation owns steer-or-resume orchestration and is distinct from the run's `SubagentRun.steer?()`, which only delivers to an already active run. The tool performs no lifecycle routing of its own. It lives in the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package so provider-bound `@deepseek-ai/dsh-tool-subagent` instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools. +The model receives one `send_message(subagent_id, message)` tool backed by `SubagentControlService.sendMessage()`. The control operation owns steer-or-resume orchestration and is distinct from the run's `SubagentRun.steer?()`, which only delivers to an already active run. The tool performs no lifecycle routing of its own. It attributes the follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the control service requires a caller-supplied `MessageSource` and carries it through both live steering and cold resume. The child model still receives ordinary user-role content, while the durable source prevents model-generated follow-ups from being classified as direct human input. A human adapter instead supplies `{ kind: 'user' }`. The tool lives in the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package so provider-bound `@deepseek-ai/dsh-tool-subagent` instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools. -- If the child has a running Task and live-steering capability, the service calls `run.steer(message)` and returns the existing Task id; it creates no Task of its own. +- If the child has a running Task and live-steering capability, the service calls `run.steer(message, source)` and returns the existing Task id; it creates no Task of its own. - If the child has no running Task, `send_message` creates a fresh Task, cold-resumes the durable session with the message, and returns the new Task id. - If the active provider cannot accept live delivery, strict steering loses a race with Task settlement, or a live child exists outside the Task association, `send_message` fails rather than silently starting, resuming, or adopting an untracked turn. @@ -107,8 +107,8 @@ Task records and active-run associations are process-local. Persistence makes th ## Testing -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id; `task_kill` during a run or during cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task; cold follow-ups accumulate turns in one durable transcript with the declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once. -- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id; `task_kill` during a run or during cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task and retains the caller source; cold follow-ups accumulate turns in one durable transcript with their source and declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once. +- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_message`, a one-shot provider keeps the plain task acknowledgement, and a resumable provider without the control service fails loud. - The keyless ACP snapshot scenario `subagent-continuable` (examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, a final durability-confirmation failure rendered through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 0c9e2e4d87..ed07abd2af 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -55,9 +55,9 @@ durable child Session ### 面向模型的 `send_message` -模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 +模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具将后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;控制服务要求调用方提供 `MessageSource`,并在在线 steering 与 cold resume 两条路径中传递该来源。child 模型收到的仍是普通的 user role 内容,而持久化的来源信息可防止模型生成的后续消息被归类为直接用户输入。用户适配器则提供 `{ kind: 'user' }`。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 -- 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message)` 并返回现有 task id;它不会创建新 Task。 +- 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message, source)` 并返回现有 task id;它不会创建新 Task。 - 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 - 如果活跃提供方无法接收在线消息、严格 steering 在与 Task 结算的竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。 @@ -107,8 +107,8 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 测试 -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,而不创建第二个 Task;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 -- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,不创建第二个 Task,并保留调用方来源;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建其来源和声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 +- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 - 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e73ce42c82..76ee56bfb3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1980,15 +1980,16 @@ startContinuable(spec: ContinuableStartSpec): ContinuableStart * @param parent - the live parent agent sending the message (model tool or * human adapter); Task access is authorized by its session id. * @param childId - the stable child session id. - * @param message - the content to deliver. + * @param message - the user-role content to deliver. + * @param source - caller-supplied attribution retained across either route. * @returns whether the message `steered` the existing Task or `started` a new one. */ -sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult +sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult ``` -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/subagent/subagent-control/src/index.ts:163`](../../packages/subagent/subagent-control/src/index.ts) +Source: [`packages/subagent/subagent-control/src/index.ts:176`](../../packages/subagent/subagent-control/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index b547305e8d..b746073424 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -6,7 +6,7 @@ The subagent seam — an agent delegating work to a child agent. Like [bash](bas Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the global `send_message`). Continuable-child orchestration lives on `ctx.subagentControl` in [dsh-subagent-control](../../packages/subagent/subagent-control). The proposals and rationale: [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) and [the continuable background subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md). -Source: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) +Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) and [`packages/subagent/subagent-control/src/index.ts`](../../packages/subagent/subagent-control/src/index.ts) ## Two kinds of capability, discovered two ways @@ -105,7 +105,16 @@ interface SubagentStartRequest { ## Continuable children: `SubagentContinuation` and `SubagentResumeRequest` -A **continuable background subagent** is a durable child session with a series of Task-backed activations. `ctx.subagentControl` (`SubagentControlService` in [dsh-subagent-control](../../packages/subagent/subagent-control)) allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the resolved start request; the provider publishes exactly that id and appends the descriptor inside the child's first turn. On follow-up, the control service loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches a fully resolved resume request through `SubagentService.resume()` to `SubagentProvider.resume()`. The seam stays Task- and persistence-agnostic — descriptor lookup and Task association live only in the control service. `startContinuable()` returns a `ContinuableStart` (both identities), and `sendMessage()` returns a `SendMessageResult` reporting whether the message `steered` the running activation's existing Task or `started` a fresh one. +A **continuable background subagent** is a durable child session with a series of Task-backed activations. `ctx.subagentControl` (`SubagentControlService` in [dsh-subagent-control](../../packages/subagent/subagent-control)) allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the resolved start request; the provider publishes exactly that id and appends the descriptor inside the child's first turn. On follow-up, the control service loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches a fully resolved resume request through `SubagentService.resume()` to `SubagentProvider.resume()`. The seam stays Task- and persistence-agnostic — descriptor lookup and Task association live only in the control service. `startContinuable()` returns a `ContinuableStart` (both identities), and `sendMessage()` returns a `SendMessageResult` reporting whether the message `steered` the running activation's existing Task or `started` a fresh one. Every sender supplies a `MessageSource`; the model-facing tool uses `CoordinatorMessageSource`, while a human adapter uses `{ kind: 'user' }`. Both project to a user-role model message, but the durable source remains distinct for policy and title consumers. + +```ts type-equiv +/** Attribution for a model coordinator's follow-up to one of its children. */ +interface CoordinatorMessageSource { + readonly kind: 'coordinator' + /** Session id of the agent whose tool call produced the follow-up. */ + readonly senderSessionId: SessionId +} +``` ```ts type-equiv /** @@ -134,6 +143,8 @@ interface SubagentResumeRequest { readonly sessionId: SessionId /** The follow-up message that starts the resumed activation's turn. */ readonly prompt: ContentBlock[] + /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ + readonly source: MessageSource /** * The live parent agent — the direct parent recorded in the persisted child * header. In-process backends reconstruct the child under this agent's @@ -203,7 +214,7 @@ interface SubagentStopReasonMap { ## A live run: `SubagentRun` -`SubagentRun` is the consumer-owned handle for a ready child — one disposable activation, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. A completed continuable result additionally means the provider confirmed the activation's final state durable; a failed required checkpoint rejects. The optional strict `steer` method advertises live delivery by presence; cold resume deliberately does NOT live here (a disposed run cannot be reconstructed after restart) — it is `SubagentProvider.resume`. +`SubagentRun` is the consumer-owned handle for a ready child — one disposable activation, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. A completed continuable result additionally means the provider confirmed the activation's final state durable; a failed required checkpoint rejects. The optional strict `steer` method advertises live delivery by presence. Cold resume is a provider-level operation: `SubagentProvider.resume` reconstructs a fresh run from the child's persisted session because the process-local run ceases to exist after disposal or process restart. ```ts type-equiv /** @@ -249,8 +260,10 @@ interface SubagentRun { * this run has settled. Throws when delivery cannot join the turn. A run * represents one disposable activation, so it has no cold-resume operation; * resuming a settled child goes through {@link SubagentProvider.resume}. + * `source` is retained on the child's logged steering message without + * changing its user role in model history. */ - steer?(content: ContentBlock[]): void + steer?(content: ContentBlock[], source: MessageSource): void } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b090b438e6..749ba48359 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -889,8 +889,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Start a continuable background child: allocate its stable session id,\n * snapshot its durable descriptor, and register the initial activation\'s\n * Task. A synchronous validation failure (a non-JSON descriptor input,\n * missing persistence, Task preflight) throws without creating a Task; the\n * method otherwise returns both identities immediately, without waiting for\n * child publication or descriptor durability. Asynchronous startup failure\n * settles the returned Task as `failed` (or `killed` when cancelled) after\n * any published run is disposed, which can leave an unmaterialized child id\n * that later by-id operations report as unavailable.\n * @param spec - provider, Task label, and the delegation request.\n * @returns the stable child id and the initial activation\'s Task id.\n */', }, { - signature: 'sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult', - jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. A throw means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the content to deliver.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */', + signature: 'sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult', + jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. A throw means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the user-role content to deliver.\n * @param source - caller-supplied attribution retained across either route.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */', }, ], }, @@ -2699,11 +2699,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentResumeRequest', - declaration: 'export interface SubagentResumeRequest {\n readonly sessionId: SessionId;\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly descriptor: SubagentDescriptorData;\n}', + declaration: 'export interface SubagentResumeRequest {\n readonly sessionId: SessionId;\n readonly prompt: ContentBlock[];\n readonly source: MessageSource;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly descriptor: SubagentDescriptorData;\n}', }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n steer?(content: ContentBlock[]): void;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n steer?(content: ContentBlock[], source: MessageSource): void;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/subagent/subagent-control/README.md b/packages/subagent/subagent-control/README.md index e65c6dcf1c..141333f719 100644 --- a/packages/subagent/subagent-control/README.md +++ b/packages/subagent/subagent-control/README.md @@ -6,7 +6,7 @@ The continuable-subagent control service (`ctx.subagentControl`): the one orches A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output. -`sendMessage(parent, childId, message)` owns steer-or-resume routing. A running activation receives live delivery through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` (`started`). Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. +`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface). diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts index ab0fab6d9d..9de5459451 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent-control/src/index.ts @@ -20,7 +20,7 @@ import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSubagentDescriptor, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' @@ -33,6 +33,19 @@ declare module 'cordis' { } } +/** Attribution for a model coordinator's follow-up to one of its children. */ +export interface CoordinatorMessageSource { + readonly kind: 'coordinator' + /** Session id of the agent whose tool call produced the follow-up. */ + readonly senderSessionId: SessionId +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + coordinator: CoordinatorMessageSource + } +} + /** Typed error for control-service routing, authorization, and delivery failures. */ export class SubagentControlError extends HarnessError { constructor(message: string, code: string, options?: ErrorOptions) { @@ -248,16 +261,20 @@ export class SubagentControlService extends Service { * @param parent - the live parent agent sending the message (model tool or * human adapter); Task access is authorized by its session id. * @param childId - the stable child session id. - * @param message - the content to deliver. + * @param message - the user-role content to deliver. + * @param source - caller-supplied attribution retained across either route. * @returns whether the message `steered` the existing Task or `started` a new one. */ - sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult { + sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult { this.assertOwnership(childId) const activation = this.activations.get(childId) if (activation !== undefined) { - return { route: 'steered', taskId: this.steerActivation(activation, parent, childId, message) } + return { + route: 'steered', + taskId: this.steerActivation(activation, parent, childId, message, source), + } } - return { route: 'started', taskId: this.resumeActivation(parent, childId, message) } + return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) } } /** @@ -290,6 +307,7 @@ export class SubagentControlService extends Service { parent: Agent, childId: SessionId, message: ContentBlock[], + source: MessageSource, ): TaskId { const taskId = activation.taskId /* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */ @@ -316,7 +334,7 @@ export class SubagentControlService extends Service { ) } try { - run.steer(message) + run.steer(message, source) } catch (error: unknown) { // Strict steering lost the race with turn settlement. Deliberately no // cold-resume fallback here: that would attach the message to a turn the @@ -337,7 +355,12 @@ export class SubagentControlService extends Service { * activation, with cancellation rechecked after the un-signalled * persistence await so an early `task_kill` prevents any later child work. */ - private resumeActivation(parent: Agent, childId: SessionId, message: ContentBlock[]): TaskId { + private resumeActivation( + parent: Agent, + childId: SessionId, + message: ContentBlock[], + source: MessageSource, + ): TaskId { const persistence = this.requirePersistence() return this.startActivation(childId, resumeLabel(message), parent, async (signal) => { let loaded: Awaited> @@ -374,6 +397,7 @@ export class SubagentControlService extends Service { return this.ctx.subagents.resume(descriptor.provider, { sessionId: childId, prompt: message, + source, parent, signal, descriptor, diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index 458e6a6960..c81b88b756 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -107,6 +107,20 @@ function message(text: string) { return [{ type: 'text' as const, text }] } +const coordinatorSource = { + kind: 'coordinator', + senderSessionId: SessionId('parent'), +} as const + +function sendMessage( + ctx: Context, + parent: Agent, + childId: SessionId, + content: ReturnType, +) { + return ctx.subagentControl.sendMessage(parent, childId, content, { kind: 'user' }) +} + describe('SubagentControlService.startContinuable', () => { it('returns both identities immediately; the Task settles with the child result after disposal', async () => { const { ctx, parent } = await setup([textResponse('first answer')]) @@ -202,7 +216,7 @@ describe('SubagentControlService.startContinuable', () => { expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain('maxDepth') // The unmaterialized child id is reported unavailable on later use. - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('hello?')) + const followUp = sendMessage(ctx, parent, started.childId, message('hello?')) expect(followUp.route).toBe('started') const failed = await waitTerminal(ctx, followUp.taskId, parent) expect(failed.status).toBe('failed') @@ -250,14 +264,14 @@ describe('SubagentControlService.sendMessage', () => { await waitPublishedRun(ctx, started.childId) expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) - expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('join'))) + expect(() => sendMessage(ctx, parent, started.childId, message('join'))) .toThrow(/provider does not accept live delivery/) let terminalDeliveryError: unknown ctx.tasks.onTaskDone((snapshot) => { if (snapshot.id !== started.taskId) return try { - ctx.subagentControl.sendMessage(parent, started.childId, message('after terminal')) + sendMessage(ctx, parent, started.childId, message('after terminal')) } catch (error: unknown) { terminalDeliveryError = error } @@ -296,7 +310,7 @@ describe('SubagentControlService.sendMessage', () => { const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local')) await waitPublishedRun(ctx, started.childId) - expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('join'))) + expect(() => sendMessage(ctx, parent, started.childId, message('join'))) .toThrow(/registry agent is not the associated activation's agent/) result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) await waitTerminal(ctx, started.taskId, parent) @@ -324,7 +338,12 @@ describe('SubagentControlService.sendMessage', () => { }, 5) }) - const delivered = ctx.subagentControl.sendMessage(parent, started.childId, message('also consider Y')) + const delivered = ctx.subagentControl.sendMessage( + parent, + started.childId, + message('also consider Y'), + coordinatorSource, + ) expect(delivered).toEqual({ route: 'steered', taskId: started.taskId }) releaseFirst() const snapshot = await waitTerminal(ctx, started.taskId, parent) @@ -334,6 +353,11 @@ describe('SubagentControlService.sendMessage', () => { // The steered content joined the SAME child turn and drove another step. const output = ctx.tasks.read(started.taskId, parent) expect(output.text).toBe('steered turn answer') + const loaded = await ctx.sessionPersistence.load(started.childId) + const steering = loaded.events.find( + (event): event is SessionEvent<'steering/message'> => event.type === 'steering/message', + ) + expect(steering?.data.message.source).toEqual(coordinatorSource) }) it('cold-resumes a settled child into a fresh Task and reports `started`', async () => { @@ -342,7 +366,12 @@ describe('SubagentControlService.sendMessage', () => { await waitTerminal(ctx, started.taskId, parent) expect(ctx.agents.get(started.childId)).toBeUndefined() - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('and then?')) + const followUp = ctx.subagentControl.sendMessage( + parent, + started.childId, + message('and then?'), + coordinatorSource, + ) expect(followUp.route).toBe('started') expect(followUp.taskId).not.toBe(started.taskId) const snapshot = await waitTerminal(ctx, followUp.taskId, parent) @@ -356,6 +385,8 @@ describe('SubagentControlService.sendMessage', () => { const userMessages = loaded.events.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message') expect(userMessages.map(event => (event.data.content[0] as { text: string }).text)) .toEqual(['child task', 'and then?']) + expect(userMessages.map(event => event.data.source)) + .toEqual([{ kind: 'user' }, coordinatorSource]) }) it('reconstructs the declared composition on cold resume', async () => { @@ -378,7 +409,7 @@ describe('SubagentControlService.sendMessage', () => { expect(descriptor?.data.persona).toBe('You are the resumable child.') expect(descriptor?.data.toolFilter).toEqual({ deny: [] }) - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('continue')) + const followUp = sendMessage(ctx, parent, started.childId, message('continue')) const snapshot = await waitTerminal(ctx, followUp.taskId, parent) expect(snapshot.status).toBe('completed') // The resumed child's system prompt carried the persona back. @@ -407,7 +438,7 @@ describe('SubagentControlService.sendMessage', () => { parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } })) await parent.whenIdle() - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('follow up')) + const followUp = sendMessage(ctx, parent, started.childId, message('follow up')) await waitTerminal(ctx, followUp.taskId, parent) const resumed = await ctx.sessionPersistence.load(started.childId) // The persisted seed boundary is unchanged and parent turn two is absent. @@ -423,7 +454,7 @@ describe('SubagentControlService.sendMessage', () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) const started = ctx.subagentControl.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('go on')) + const followUp = sendMessage(ctx, parent, started.childId, message('go on')) const childAgents: Agent[] = [] const stop = ctx.on('agent/created', (agent: Agent) => { @@ -443,7 +474,7 @@ describe('SubagentControlService.sendMessage', () => { const started = ctx.subagentControl.startContinuable(startSpec(otherParent)) await waitTerminal(ctx, started.taskId, otherParent) - const attempt = ctx.subagentControl.sendMessage(parent, started.childId, message('mine now')) + const attempt = sendMessage(ctx, parent, started.childId, message('mine now')) expect(attempt.route).toBe('started') const snapshot = await waitTerminal(ctx, attempt.taskId, parent) expect(snapshot.status).toBe('failed') @@ -462,7 +493,7 @@ describe('SubagentControlService.sendMessage', () => { await handle.agent.whenIdle() await handle.dispose() - const attempt = ctx.subagentControl.sendMessage(parent, SessionId('plain-child'), message('continue?')) + const attempt = sendMessage(ctx, parent, SessionId('plain-child'), message('continue?')) const snapshot = await waitTerminal(ctx, attempt.taskId, parent) expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain( @@ -472,9 +503,9 @@ describe('SubagentControlService.sendMessage', () => { it('derives fallback and bounded labels for resumed activations', async () => { const { ctx, parent } = await setup([]) - const blank = ctx.subagentControl.sendMessage(parent, SessionId('blank-child'), message(' ')) + const blank = sendMessage(ctx, parent, SessionId('blank-child'), message(' ')) const longText = 'x'.repeat(100) - const long = ctx.subagentControl.sendMessage(parent, SessionId('long-child'), message(longText)) + const long = sendMessage(ctx, parent, SessionId('long-child'), message(longText)) expect(ctx.tasks.get(blank.taskId, parent).label).toBe('subagent follow-up') expect(ctx.tasks.get(long.taskId, parent).label).toBe(`${'x'.repeat(79)}…`) @@ -492,9 +523,9 @@ describe('SubagentControlService.sendMessage', () => { meta: { parentSession: parent.id }, agentOptions: { provider: 'mock', model: 'mock' }, }) - expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello'))) + expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) .toThrow(SubagentControlError) - expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello'))) + expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) .toThrow(/outside control-service ownership.*not delivered/) await handle.dispose() }) @@ -535,13 +566,13 @@ describe('SubagentControlService.sendMessage', () => { // Strict steering finds the settled child, fails loud, and does NOT start // a cold resume within this call. - expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('too late?'))) + expect(() => sendMessage(ctx, parent, started.childId, message('too late?'))) .toThrow(/not delivered/) expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId]) releaseDispose() await waitTerminal(ctx, started.taskId, parent) // AFTER the Task settles, retry legitimately starts the next activation. - const retry = ctx.subagentControl.sendMessage(parent, started.childId, message('retry')) + const retry = sendMessage(ctx, parent, started.childId, message('retry')) expect(retry.route).toBe('started') await waitTerminal(ctx, retry.taskId, parent) }) @@ -550,7 +581,7 @@ describe('SubagentControlService.sendMessage', () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) const started = ctx.subagentControl.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('more')) + const followUp = sendMessage(ctx, parent, started.childId, message('more')) const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' }) expect(() => ctx.tasks.get(followUp.taskId, other)).toThrow(/belongs to another session/) }) @@ -569,7 +600,7 @@ describe('SubagentControlService.sendMessage', () => { return realLoad(id) } - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('follow up')) + const followUp = sendMessage(ctx, parent, started.childId, message('follow up')) expect(ctx.tasks.kill(followUp.taskId, parent)).toBe('requested') releaseLoad() const snapshot = await waitTerminal(ctx, followUp.taskId, parent) @@ -591,11 +622,11 @@ describe('SubagentControlService.sendMessage', () => { return realLoad(id) } - const first = ctx.subagentControl.sendMessage(parent, started.childId, message('first follow-up')) + const first = sendMessage(ctx, parent, started.childId, message('first follow-up')) expect(first.route).toBe('started') // The association is installed synchronously, so the competing caller // observes the pending activation instead of starting a duplicate resume. - expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('second follow-up'))) + expect(() => sendMessage(ctx, parent, started.childId, message('second follow-up'))) .toThrow(/not delivered/) releaseLoad() const snapshot = await waitTerminal(ctx, first.taskId, parent) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index aaad34ccf8..f48257ad02 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -11,7 +11,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' -import { createUserMessage, errorChain, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { createUserMessage, errorChain, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentDescriptorData, @@ -70,6 +70,14 @@ export interface InProcessRunOptions { /** Whether one activation must prove its final state durable before success. */ type Durability = 'best-effort' | 'required' +/** Activation-specific inputs to the shared in-process driver. */ +interface DriveTurnOptions { + readonly durability: Durability + /** Attribution for a resumed activation's follow-up prompt. */ + readonly source?: MessageSource + readonly structured?: StructuredAttachment +} + /** Error used when cancellation wins before the child publication boundary. */ function prePublicationAbort(): Error { return new Error('subagent request was aborted before child publication') @@ -177,8 +185,10 @@ export async function startInProcessRun( request.prompt, childId, seedLength, - request.continuation === undefined ? 'best-effort' : 'required', - structured, + { + durability: request.continuation === undefined ? 'best-effort' : 'required', + ...structured === undefined ? {} : { structured }, + }, ) } @@ -214,7 +224,14 @@ export async function resumeInProcessRun(request: SubagentResumeRequest): Promis // The result boundary is this activation's own work: everything already in // the resumed transcript belongs to earlier turns. const resumePoint = handle.agent.session.events.length - return driveTurn(handle, request.signal, request.prompt, request.sessionId, resumePoint, 'required') + return driveTurn( + handle, + request.signal, + request.prompt, + request.sessionId, + resumePoint, + { durability: 'required', source: request.source }, + ) } /** @@ -230,10 +247,10 @@ function driveTurn( prompt: ContentBlock[], childId: SessionId, boundary: number, - durability: Durability, - structured?: StructuredAttachment, + options: DriveTurnOptions, ): SubagentRun | Promise { const child = handle.agent + const { durability, source, structured } = options // Agent creation detaches its creation-only abort listener before returning. // Close the narrow handoff race before installing the live-run listener. if (signal.aborted) { @@ -249,7 +266,7 @@ function driveTurn( const result: Promise = (async () => { try { - child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } })) + child.followup(createUserMessage({ content: prompt, source: source ?? { kind: 'user' } })) await child.whenIdle() if (durability === 'required') { try { @@ -282,31 +299,27 @@ function driveTurn( flags.cancelled = true return handle.dispose() }, - steer(content: ContentBlock[]): void { - // Strict live delivery: the synchronous checks and the Agent.steer() - // call share one frame, so delivery joins the observed turn or throws. - // Agent.steer()'s own idle fallback would instead QUEUE the message and + steer(content: ContentBlock[], steeringSource: MessageSource): void { + // Strict live delivery: the synchronous checks and Agent.trySteer() share + // one frame, so delivery joins the observed step or throws. The ordinary + // Agent.steer() idle fallback would instead queue the message and // start a new, untracked turn after this run's result was read. if (child.status !== 'running') { throw new Error(`subagent child "${childId}" is not running; the message was not delivered`) } - // The status stays `running` through the closed turn's durability flush, - // and the loop DISCARDS terminal-stopped steering drained after turn - // close instead of recording it. Requiring an open turn keeps - // acknowledged delivery honest. + // Status stays `running` through the closed turn's durability flush, when + // ordinary steering would queue a later turn. Requiring an open turn + // keeps this activation's acknowledged delivery honest. const lastBoundary = child.session.events.findLast( event => event.type === 'turn/start' || event.type === 'turn/end', ) if (lastBoundary?.type !== 'turn/start') { throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`) } - // Turn settlement only runs between steps: with no step open, the loop - // may be awaiting its continuation/turn-stopping checkpoint, where - // pending steering was already folded and a later arrival would miss - // this turn. A message accepted during an OPEN step is instead - // drained and recorded at that step's settlement checkpoint before any - // terminal decision (cancellation remains the documented shared-outcome - // race). + // Between steps there is no current step whose final drain can own strict + // delivery. A message accepted during an open step is recorded at that + // step's settlement checkpoint before the continuation decision + // (cancellation remains the documented shared-outcome race). const lastStep = child.session.events.findLast( event => event.type === 'step/start' || event.type === 'step/end', ) @@ -324,7 +337,7 @@ function driveTurn( if (child.trySteer === undefined) { throw new Error(`subagent child "${childId}" agent does not support strict steering; the message was not delivered`) } - if (!child.trySteer(createUserMessage({ content, source: { kind: 'user' } }))) { + if (!child.trySteer(createUserMessage({ content, source: steeringSource }))) { throw new Error(`subagent child "${childId}" passed its steering checkpoint; the message was not delivered`) } }, diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3ed95d159d..a9ef98a892 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -130,7 +130,7 @@ describe('in-process structured output', () => { if (session.header.parentSession === undefined || run === undefined || event.type !== 'tool/result' || rejected !== undefined) return try { - run.steer?.([{ type: 'text', text: 'one more thing' }]) + run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' }) } catch (error: unknown) { rejected = error } diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index bcec292d93..b26ca6b84d 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -262,6 +262,7 @@ describe('startInProcessRun', () => { await expect(resumeInProcessRun({ sessionId: SessionId('resumed-child'), prompt: [{ type: 'text', text: 'continue' }], + source: { kind: 'user' }, parent, signal: controller.signal, descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, @@ -309,6 +310,7 @@ describe('startInProcessRun', () => { const run = await resumeInProcessRun({ sessionId: childId, prompt: [{ type: 'text', text: 'continue' }], + source: { kind: 'plugin', plugin: 'test-coordinator' }, parent, signal: new AbortController().signal, descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, @@ -384,7 +386,7 @@ describe('startInProcessRun', () => { const run = await startInProcessRun(request(parent), {}) await run.result // The child is idle after its turn: Agent.steer() would silently QUEUE. - expect(() => { run.steer!([{ type: 'text', text: 'late' }]) }) + expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) }) .toThrow(/not running; the message was not delivered/) const child = ctx.agents.get(run.id)! expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) @@ -410,7 +412,9 @@ describe('startInProcessRun', () => { }, 5) }) expect(child.status).toBe('running') - expect(() => { run.steer!([{ type: 'text', text: 'too late for this turn' }]) }) + expect(() => { + run.steer!([{ type: 'text', text: 'too late for this turn' }], { kind: 'user' }) + }) .toThrow(/between steps; the message was not delivered/) releaseStop!() await run.result @@ -427,10 +431,10 @@ describe('startInProcessRun', () => { if (session.header.parentSession === undefined || run === undefined) return if (event.type === 'assistant/chunk' && !seeded) { seeded = true - run.steer?.([{ type: 'text', text: 'accepted before the drain' }]) + run.steer?.([{ type: 'text', text: 'accepted before the drain' }], { kind: 'user' }) } else if (event.type === 'steering/message' && rejected === undefined) { try { - run.steer?.([{ type: 'text', text: 'after the drain began' }]) + run.steer?.([{ type: 'text', text: 'after the drain began' }], { kind: 'user' }) } catch (error: unknown) { rejected = error } @@ -493,7 +497,9 @@ describe('startInProcessRun', () => { } as unknown as Agent const run = await startInProcessRun(request(parent), {}) - expect(() => { run.steer!([{ type: 'text', text: 'unsupported strict delivery' }]) }) + expect(() => { + run.steer!([{ type: 'text', text: 'unsupported strict delivery' }], { kind: 'user' }) + }) .toThrow(/does not support strict steering; the message was not delivered/) await run.dispose() await run.result @@ -520,7 +526,7 @@ describe('startInProcessRun', () => { }, 5) }) expect(child.status).toBe('running') - expect(() => { run.steer!([{ type: 'text', text: 'into the void' }]) }) + expect(() => { run.steer!([{ type: 'text', text: 'into the void' }], { kind: 'user' }) }) .toThrow(/turn has already closed; the message was not delivered/) releaseFlush!() await run.result diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 1e43b074b4..8b55dd25f9 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -246,7 +246,7 @@ describe('dsh-subagent-spawn', () => { // Strict live-only contract: after the child settles, delivery fails loud // rather than falling back to Agent.steer()'s idle queue (which would // start an untracked turn). - expect(() => { run.steer!([{ type: 'text', text: 'late' }]) }) + expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) }) .toThrow(/not running; the message was not delivered/) await run.dispose() }) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 0b76723e26..0806a01554 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -6,7 +6,7 @@ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { Branded } from '@deepseek-ai/dsh-brand' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' import type { SubagentDescriptorData } from './descriptor.ts' @@ -128,6 +128,8 @@ export interface SubagentResumeRequest { readonly sessionId: SessionId /** The follow-up message that starts the resumed activation's turn. */ readonly prompt: ContentBlock[] + /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ + readonly source: MessageSource /** * The live parent agent — the direct parent recorded in the persisted child * header. In-process backends reconstruct the child under this agent's @@ -228,8 +230,10 @@ export interface SubagentRun { * this run has settled. Throws when delivery cannot join the turn. A run * represents one disposable activation, so it has no cold-resume operation; * resuming a settled child goes through {@link SubagentProvider.resume}. + * `source` is retained on the child's logged steering message without + * changing its user role in model history. */ - steer?(content: ContentBlock[]): void + steer?(content: ContentBlock[], source: MessageSource): void } /** diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index d04599a4d2..69dacf70f6 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -118,6 +118,7 @@ describe('SubagentService', () => { await expect(subagents.resume('one-shot', { sessionId, prompt: [{ type: 'text', text: 'continue' }], + source: { kind: 'user' }, parent, signal, descriptor, diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 7654012145..6a6026e3fd 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -2,7 +2,7 @@ The globally named `send_message` tool: a thin adapter over `ctx.subagentControl.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers the one shared control tool, so multiple delegation tools never register duplicate global controls. -The tool performs no lifecycle routing. The control service decides between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child; the tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered. +The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the control service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered. ## Model Experience diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 3c537f471c..959ff8eb49 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -67,7 +67,12 @@ export function apply(ctx: Context): void { throw new Error('send_message requires a calling agent (exec.agent was undefined)') } const message: ContentBlock[] = [{ type: 'text', text: args.message }] - const result = ctx.subagentControl.sendMessage(parent, SessionId(args.subagent_id), message) + const result = ctx.subagentControl.sendMessage( + parent, + SessionId(args.subagent_id), + message, + { kind: 'coordinator', senderSessionId: parent.id }, + ) return Promise.resolve(result) }, })) diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index b54eb6508a..927162d21d 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -83,17 +83,27 @@ describe('dsh-tool-subagent-control', () => { expect(text(result)).toBe(`message started task subagent-2 continuing subagent ${started.childId}`) const collected = await callTool(ctx, 'task_output', { task_id: 'subagent-2', wait: true }, parent) expect(text(collected)).toBe('second answer\n[status: completed]') + const loaded = await ctx.sessionPersistence.load(started.childId) + const followUp = loaded.events.findLast(event => + event.type === 'user/message', + ) + expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({ + kind: 'coordinator', + senderSessionId: parent.id, + }) }) it('renders the steered route when the child is still running', async () => { // Script the child's single turn as two steps: the steer joins mid-turn. const { ctx, parent } = await setup([]) let steered: string | undefined + let source: unknown // Reach past the tool into the control service to fake a running route // deterministically: the tool is a thin adapter, so its steered wording is // what this test pins. - ctx.subagentControl.sendMessage = (agent, _childId, message) => { + ctx.subagentControl.sendMessage = (agent, _childId, message, messageSource) => { steered = (message[0] as { text: string }).text + source = messageSource return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } } const result = await callTool(ctx, 'send_message', { @@ -102,6 +112,7 @@ describe('dsh-tool-subagent-control', () => { }, parent) expect(result.isError).toBe(false) expect(steered).toBe('also consider Y') + expect(source).toEqual({ kind: 'coordinator', senderSessionId: parent.id }) expect(text(result)).toBe('message delivered to running task subagent-9') }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d8c7ee2715..c8aeb86a39 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -162,6 +162,7 @@ export const LINK_MAP: Readonly> = { SpillRef: 'spill.md', ContinuableStart: 'subagent.md', ContinuableStartSpec: 'subagent.md', + CoordinatorMessageSource: 'subagent.md', SendMessageResult: 'subagent.md', SubagentProvider: 'subagent.md', SubagentResumeRequest: 'subagent.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 7dfd3195a8..9e8f68177d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1099,6 +1099,11 @@ "symbol": "SubagentContinuation", "source": "packages/subagent/subagent/src/types.ts" }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "CoordinatorMessageSource", + "source": "packages/subagent/subagent-control/src/index.ts" + }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResumeRequest", From e1f7eeeb955e00243a971cdd5fdc43546d44df17 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 14:32:19 +0800 Subject: [PATCH 035/114] fix(subagent): confirm steering request admission --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 8 +- ...-21-continuable-background-subagents.zh.md | 8 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 14 +- docs/architecture.zh.md | 14 +- docs/cordis-catalog/events.md | 32 +-- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/core.md | 30 +-- docs/core-data-structures/subagent.md | 27 +-- docs/event-producer-consumer.md | 32 +-- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../time-context/tests/time-context.spec.ts | 2 +- .../tmux-context/tests/tmux-context.spec.ts | 2 +- .../tests/workspace-context.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 16 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 + packages/core/agent-loop/README.zh.md | 2 + packages/core/agent-loop/src/agent.ts | 222 ++++++++++++----- packages/core/agent-loop/tests/agent.spec.ts | 3 +- packages/core/agent-loop/tests/loop.spec.ts | 11 +- packages/core/agent/README.md | 3 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/types.ts | 42 ++-- packages/core/agent/tests/agent.spec.ts | 2 +- .../tests/tools.spec.ts | 2 +- .../command-goal/tests/command-goal.spec.ts | 2 +- packages/goal/goal/tests/goal.spec.ts | 2 +- packages/goal/goal/tests/projection.spec.ts | 2 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 2 +- packages/pty/pty-local/tests/index.spec.ts | 6 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 2 +- .../tool-bash-persistent/tests/tools.spec.ts | 2 +- .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 4 +- packages/subagent/subagent-control/README.md | 2 +- .../subagent/subagent-control/src/index.ts | 23 +- .../tests/subagent-control.spec.ts | 130 +++++++--- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 4 +- .../subagent/subagent-inprocess/README.zh.md | 4 +- .../subagent/subagent-inprocess/src/index.ts | 47 +--- .../tests/structured.spec.ts | 20 +- .../tests/subagent-inprocess.spec.ts | 226 +++++++----------- .../tests/subagent-spawn.spec.ts | 8 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 4 +- packages/subagent/subagent/src/types.ts | 23 +- .../tests/tool-subagent-control.spec.ts | 2 +- .../tasks/tasks-local/tests/tasks.spec.ts | 2 +- packages/ui/tui/tests/harness.ts | 2 +- packages/ui/tui/tests/tui.spec.ts | 12 +- scripts/doc-budgets.manifest.json | 2 +- 58 files changed, 565 insertions(+), 480 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 10165b1d7a..5c1d407e1d 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-continuable-background-subagents.md: 6552db82dc5cf1fabac8f18dd347cc8735f73587 -2026-07-21-continuable-background-subagents.zh.md: ed07abd2af34397d056cc022fc451e6397964acb +2026-07-21-continuable-background-subagents.md: b5683f7e4a81a65b176ff4b4306c1ad0b761cc58 +2026-07-21-continuable-background-subagents.zh.md: 0b0f22d0945bf270267df1698b9145f0ab4b04f1 diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index 6552db82dc..b5683f7e4a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -49,7 +49,7 @@ For a continuable initial activation, the control service allocates the stable c Every continuable child turn is admitted through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the control service synchronously compares its association with `ctx.agents.get(childId)`. A registry Agent with no association, or a registry Agent different from the associated `run.localAgent`, is an ownership conflict: the control service fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary. -Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability with synchronous checks followed by the default Agent loop's optional atomic `trySteer()`: the child must be `running`, its turn and step must still be open in the log, the step's final steering drain must not have begun, and no structured capture may have committed. The loop closes `trySteer()` acceptance before draining and entering `agent/post-step`, so a terminal stop cannot discard an acknowledged message from that window. A loop without `trySteer()` cannot back strict in-process delivery. Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict operation, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. +Routing follows the Task association. A running Task accepts live delivery through the run's optional confirmed `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork first synchronously require the child to be `running` and reject an already committed structured capture, then call `Agent.steer()` and await that exact message's admission receipt. The default loop gives every steering item a message-owned receipt and resolves it `admitted` only after a successful pre-step has appended the message, captured the immutable request history, and committed `step/start`; terminal turn policy, cancellation, and disposal resolve pending receipts `rejected`. A non-terminal turn close may carry pending steering into a later queued turn without acknowledging it. Providers must check the live status before `Agent.steer()` so its idle path cannot start a turn outside the observed run. If Task settlement or terminal policy wins after association lookup but before request admission, `steer()` rejects, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. The control service does not serialize two callers that race a stopped child through paths outside it, nor does it model a separate settling phase between result production and disposal. The synchronous association install before the producer's first await admits one activation per child in this process — a competing `sendMessage` during resume load observes the pending activation and fails explicitly — while a bypassing publication still loses at the Agent registry's same-session collision boundary. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction. @@ -59,7 +59,7 @@ The model receives one `send_message(subagent_id, message)` tool backed by `Suba - If the child has a running Task and live-steering capability, the service calls `run.steer(message, source)` and returns the existing Task id; it creates no Task of its own. - If the child has no running Task, `send_message` creates a fresh Task, cold-resumes the durable session with the message, and returns the new Task id. -- If the active provider cannot accept live delivery, strict steering loses a race with Task settlement, or a live child exists outside the Task association, `send_message` fails rather than silently starting, resuming, or adopting an untracked turn. +- If the active provider cannot accept live delivery, confirmed steering loses its admission race, or a live child exists outside the Task association, `send_message` fails rather than silently starting, resuming, or adopting an untracked turn. The service result identifies the route as `steered` with the existing Task id or `started` with the new Task id. Failure is explicit and says that the message was not delivered. The model-facing tool renders these distinctions so timing-dependent routing is observable to the caller. @@ -73,7 +73,7 @@ The control service snapshots every descriptor input with the seam's `snapshotSu The versioned descriptor (`SUBAGENT_DESCRIPTOR_VERSION` in [descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts)) contains the subagent provider name, resolved child `agentOptions.provider` and `agentOptions.model`, and optional `persona` and `toolFilter`. It does not snapshot the merge-extensible `AgentOptions` object: unrelated extension values cannot make continuation fail merely because they are not JSON. It deliberately omits `subagentDepth`; cold resume relies on the persisted header's `delegationDepth` rather than reconstructing depth from the descriptor. `outputSchema` belongs to one activation's result contract rather than durable child composition. The child header remains authoritative for the child id, `cwd`, `parentSession`, `seedLength`, and `delegationDepth`, while the persisted child transcript owns the fork seed and subsequent history. [`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) takes the maximum of header and runtime values, so reconstructed runtime options may deepen the persisted value but never lower it and a resumed child cannot regain a top-level delegation budget. -Cold resume cannot depend on an optional method of the old `SubagentRun`, because that run has been disposed and is not retained across process restart. `SubagentRun` has no `resume` operation: a run represents one disposable activation and exposes only activation-scoped operations. The former `SubagentRun.sendMessage?()` capability is named `SubagentRun.steer?()` so its strict live-only contract cannot be confused with service orchestration or the model-facing tool. +Cold resume cannot depend on an optional method of the old `SubagentRun`, because that run has been disposed and is not retained across process restart. `SubagentRun` has no `resume` operation: a run represents one disposable activation and exposes only activation-scoped operations. The former `SubagentRun.sendMessage?()` capability is named `SubagentRun.steer?()` so its confirmed live-only contract cannot be confused with service orchestration or the model-facing tool. `SubagentControlService`'s resume path loads the known child session, folds its descriptor, authorizes the persisted `parentSession`, and runs inside the Task it creates. It passes a fully resolved request, including the Task-owned cancellation signal, to the low-level `SubagentService.resume(provider, request)`, whose only responsibility is capability-checked provider dispatch and the ordinary run lifecycle observation used by `start`. The selected `SubagentProvider.resume?()` owns transport-specific reconstruction (in-process: `parent.ctx.agents.resume` under the currently loaded parent scope) and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag exists. `SubagentControlService.sendMessage()` chooses between the associated run's `steer?()` operation and this cold-resume path. Neither the low-level service nor a provider enumerates durable children or associates Tasks. @@ -97,7 +97,7 @@ Task records and active-run associations are process-local. Persistence makes th **Create a Task for every message.** Steering joins an existing turn and has no independent final result, so a Task created for steering would duplicate the active Task or report a result it does not own. Only a message that starts an activation creates a Task. -**Split `send_message` and `follow_up`.** Separate strict operations expose an implementation-state distinction to the model without removing stopped-child races. One operation follows the Claude Code model: deliver to running work or resume a new Task-backed lifecycle. +**Split `send_message` and `follow_up`.** Separate delivery operations expose an implementation-state distinction to the model without removing stopped-child races. One operation follows the Claude Code model: deliver to running work or resume a new Task-backed lifecycle. **Keep `resume?()` on the disposed run.** Retaining a disposed `SubagentRun` only to call `resume()` makes the old run double as a durable child handle and cannot reconstruct that object after restart. Service dispatch plus provider reconstruction makes the persistence boundary explicit. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index ed07abd2af..0b0f22d094 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -49,7 +49,7 @@ durable child Session 每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,控制服务会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:控制服务会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。 -系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 先执行同步检查,再调用默认 Agent 循环所提供的可选原子操作 `trySteer()`,以实现该功能:child 必须处于 `running` 状态,其轮次和步骤在日志中必须仍然打开,该步骤最后一次排空 steering(中途引导)必须尚未开始,且不得已有结构化捕获提交。循环会在排空 steering 并进入 `agent/post-step` 前关闭 `trySteer()` 准入,使终止性 stop 无法丢弃在这个窗口中已确认接收的消息。不提供 `trySteer()` 的循环无法支撑严格的进程内消息投递。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering,因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格操作之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且提供确认语义的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 会先同步要求 child 处于 `running` 状态,并拒绝已经提交结构化捕获的 child;随后调用 `Agent.steer()`,等待该消息专属的准入回执。默认循环会为每个 steering 项目提供一份归属于该消息的回执;只有在 `agent/pre-step` 成功后追加该消息、捕获不可变的请求历史并提交 `step/start`,回执才会解析为 `admitted`。终止型轮次策略、取消和 dispose(资源释放)会将待处理回执解析为 `rejected`。非终止型轮次关闭可以把待处理 steering 带入后续排队轮次,但不会确认其准入。提供方必须在调用 `Agent.steer()` 前检查存活状态,避免其 idle 路径在观察到的 run 之外启动轮次。如果查找关联之后、请求获准之前,Task 结算或终止策略率先完成,`steer()` 会拒绝,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 @@ -59,7 +59,7 @@ durable child Session - 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message, source)` 并返回现有 task id;它不会创建新 Task。 - 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 -- 如果活跃提供方无法接收在线消息、严格 steering 在与 Task 结算的竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。 +- 如果活跃提供方无法接收在线消息、带确认语义的 steering 在准入竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。 服务结果将路由标识为 `steered` 并携带现有 task id,或标识为 `started` 并携带新的 task id。失败结果会明确说明消息未送达。面向模型的工具会呈现这些差异,让调用方能够观察由时序决定的实际路由。 @@ -73,7 +73,7 @@ durable child Session 版本化描述符([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)包含 subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果契约,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`seedLength` 和 `delegationDepth` 的权威信息,持久化 child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。 -从持久化存储恢复不能依赖旧 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。`SubagentRun` 不含 `resume` 操作:run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。原有的 `SubagentRun.sendMessage?()` 功能改名为 `SubagentRun.steer?()`,以免其严格的仅在线契约与服务编排或面向模型的工具混淆。 +从持久化存储恢复不能依赖旧 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。`SubagentRun` 不含 `resume` 操作:run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。原有的 `SubagentRun.sendMessage?()` 功能改名为 `SubagentRun.steer?()`,以免其提供确认语义且仅适用于在线消息的契约与服务编排或面向模型的工具混淆。 `SubagentControlService` 的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。 @@ -97,7 +97,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 **为每条消息创建 Task。** 发送到现有 run 的消息会加入已有轮次,不产生独立的最终结果;为这类消息创建 Task,会重复当前 Task,或报告一个它并不拥有的结果。只有启动新激活的消息才会创建 Task。 -**拆分 `send_message` 与 `follow_up`。** 两个严格操作会向模型暴露实现状态差异,却无法消除 child 已停止时的竞态。单一操作采用 Claude Code 模型:向运行中的工作发送消息,或恢复一个由新 Task 支撑的生命周期。 +**拆分 `send_message` 与 `follow_up`。** 两个独立的投递操作会向模型暴露实现状态差异,却无法消除 child 已停止时的竞态。单一操作采用 Claude Code 模型:向运行中的工作发送消息,或恢复一个由新 Task 支撑的生命周期。 **在已 dispose 的 run 上保留 `resume?()`。** 如果仅为调用 `resume()` 而保留已 dispose 的 `SubagentRun`,旧 run 会同时充当持久化 child handle,而且进程重启后无法重建该对象。由服务分发、提供方重建,可明确表达持久化边界。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 47486ee35f..d7d4618aac 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: c5788ad33dc87e104dbdf0f420ac937af9ff2662 -architecture.zh.md: db98ead01d5bcb689a2cfd199eaae059763ad19e +architecture.md: d7beb60baac3c550eb008d414158d9a05181337a +architecture.zh.md: 200f82df3d936b45f4aeef0cb080c55483af602a diff --git a/docs/architecture.md b/docs/architecture.md index c5788ad33d..d7beb60baa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,12 +93,12 @@ forever: append prompt + additional contexts as separate 'user/message' events STEP loop: agent/step - drain injected context and steering (steering bypasses prompt-submit) assemble system prompt and tools materialize changed runtime context as sourced 'user/message' + drain injected context and provisional steering (steering bypasses prompt-submit) snapshot the derived messages (the reconstruction boundary) 'step/start' - open strict-steering acceptance + admit the drained steering receipts agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' @@ -107,10 +107,10 @@ forever: parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - close strict-steering acceptance, then drain accepted tool context and steering + drain accepted tool context after all results; keep steering provisional 'step/end' - continue for tools or steering unless a result concluded the turn - otherwise agent/turn-stopping -> drain -> continue only for steering + continue for tools or steering unless a result concluded the turn and rejects pending steering + otherwise agent/turn-stopping -> drain context -> continue only for steering close the next-step acceptance window 'turn/end' -> agent/settled start the next waking queued message, or emit agent/status(idle) @@ -122,7 +122,7 @@ idle inject: Each step assembles ordered stable system sections, cache-safe dynamic contexts, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Admission-time and active-turn `inject()` stage for the next step; post-tool `additionalContexts` settles after results. Steering shares that staging boundary and requests another step. The default loop closes its optional `trySteer()` acceptance immediately before the final steering drain; ordinary `steer()` keeps its best-effort routing semantics. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. +Admission-time and active-turn `inject()` stage for the next step; tool-time injection and post-tool `additionalContexts` settle after results. Steering shares the outbox but remains provisional until a request admits it. `steer()` returns a message-owned receipt: after `agent/step` and asynchronous prompt assembly succeed, the loop commits the stable batch, snapshots request history, opens `step/start`, then resolves its receipts as admitted with the turn and step; later arrivals wait. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never opens a step rejects affected receipts, while `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize one retry turn between failed-step and turn close; cancellation wins. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). @@ -136,7 +136,7 @@ Turn and step events are turn-enclosed. Idle `user/message` and standalone `comp ### Agent Handles -`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, `steer()`, optional `trySteer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. The default loop's `trySteer()` atomically rejects after the current step's final steering drain begins, while ordinary `steer()` retains best-effort routing. `cancel()` and `whenIdle()` control lifecycle. Awaited disposal owns teardown. +`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, receipt-bearing `steer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. Await a steering receipt when request admission matters; best-effort UI steering may ignore it. `cancel()` and `whenIdle()` control lifecycle. Caller, factory, and consumer co-own teardown through one awaited disposer. ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index db98ead01d..200f82df3d 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -93,12 +93,12 @@ forever: append prompt + additional contexts as separate 'user/message' events STEP loop: agent/step - drain injected context and steering (steering bypasses prompt-submit) assemble system prompt and tools materialize changed runtime context as sourced 'user/message' + drain injected context and provisional steering (steering bypasses prompt-submit) snapshot the derived messages (the reconstruction boundary) 'step/start' - open strict-steering acceptance + admit the drained steering receipts agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' @@ -107,10 +107,10 @@ forever: parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - close strict-steering acceptance, then drain accepted tool context and steering + drain accepted tool context after all results; keep steering provisional 'step/end' - continue for tools or steering unless a result concluded the turn - otherwise agent/turn-stopping -> drain -> continue only for steering + continue for tools or steering unless a result concluded the turn and rejects pending steering + otherwise agent/turn-stopping -> drain context -> continue only for steering close the next-step acceptance window 'turn/end' -> agent/settled start the next waking queued message, or emit agent/status(idle) @@ -122,7 +122,7 @@ idle inject: 每个步骤都会组装有序的稳定系统提示词片段、缓存安全的动态上下文、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用这一暂存边界,并请求再执行一个步骤。默认循环会在最后一次排空 steering 前立即关闭其可选 `trySteer()` 的准入;普通 `steer()` 保留尽力路由语义。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 +接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行期间的注入和工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用 outbox,但在请求接纳前始终处于待准入状态。`steer()` 会返回归属于该消息的回执:`agent/step` 和异步提示词组装成功后,循环提交稳定批次、捕获请求历史并开启 `step/start`,再将其回执解析为已准入并附带轮次与步骤;后续消息继续等待。结束轮次的工具结果、广义取消、dispose(资源释放),以及已领取 idle-steering 消息却从未开启步骤的轮次,都会拒绝受影响的回执;`cancel(..., { keepInbox: true })` 和非终止型路由则保留待处理投递。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在失败步骤与轮次关闭之间授权一个重试轮次;取消优先。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 @@ -136,7 +136,7 @@ idle inject: ### Agent 句柄 -`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`,或使用 `followup()`、`steer()`、可选的 `trySteer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。当前步骤开始最后一次排空 steering 后,默认循环的 `trySteer()` 会原子地拒绝调用,而普通 `steer()` 保留尽力路由语义。`cancel()` 与 `whenIdle()` 控制生命周期。需等待完成的资源释放负责拆卸。 +`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()` 或 `followup()`、带回执的 `steer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。需要确认请求准入时应等待 steering 回执;尽力执行的 UI steering 可以忽略它。`cancel()` 与 `whenIdle()` 控制生命周期。调用方、工厂和消费方通过同一个需等待完成的 disposer 共同拥有拆卸过程。 ### Agent 作用域 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f118c5ab44..724867f0d8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:274`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:280`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:283`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:457`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:327`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:339`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,7 +161,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts) ### `agent/inbox/update` — emit @@ -181,7 +181,7 @@ A still-pending queued item changed content. The item id, placement, and positio Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:317`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -204,7 +204,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:370`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:376`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -228,7 +228,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:396`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:402`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -258,7 +258,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:415`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:421`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -280,7 +280,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:356`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -305,7 +305,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:444`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:450`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -325,7 +325,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -349,7 +349,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:383`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:389`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -375,7 +375,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:430`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:436`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 76ee56bfb3..43965ad9a3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1970,7 +1970,7 @@ startContinuable(spec: ContinuableStartSpec): ContinuableStart * Deliver one message to a known continuable child: steer its running * activation, or cold-resume the durable session into a fresh Task-backed * activation. The two routes are reported distinctly so timing-dependent - * routing is observable. A throw means the message was NOT delivered — in + * routing is observable. Rejection means the message was NOT delivered — in * particular, losing a race with Task settlement does not fall through to * cold resume within the same call; a later retry after Task terminal may * start the next activation. The started Task owns descriptor lookup and @@ -1984,7 +1984,7 @@ startContinuable(spec: ContinuableStartSpec): ContinuableStart * @param source - caller-supplied attribution retained across either route. * @returns whether the message `steered` the existing Task or `started` a new one. */ -sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult +async sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise ``` Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 91c1f1fabb..795256a2b3 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -560,6 +560,8 @@ interface CancelOptions { } ``` +`SteeringReceipt.outcome` always resolves. `admitted` identifies the turn and step whose immutable request history contains that exact message; `rejected` means lifecycle or terminal policy discarded it first. Synchronous input validation still throws from `steer()`. + ```ts type-equiv /** Stable runtime cause accepted by {@link Agent.cancel}. */ type AgentCancelCause = @@ -661,26 +663,18 @@ interface Agent { followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn — the - * `next-step`/wakeup preset of {@link send}. It stages for the next steering - * checkpoint before a request or stop decision. If the activity fails before - * that boundary, the remainder stays staged without waking the agent; retry - * or a later prompt takes it. Outside that window steering falls back to a - * woken follow-up turn, while cancellation or disposal may discard pending - * steering. + * Submit steering with a message-owned admission receipt — the + * `next-step`/wakeup preset of {@link send}. During prompt admission or an + * open turn, the message waits in the steering FIFO until a committed step + * snapshots it; outside that window it enters the ordinary queued FIFO. The + * receipt resolves `admitted` only after the message joins that step's + * immutable request history, or `rejected` when terminal policy, + * cancellation, or disposal discards it first. A non-terminal turn close may + * leave it staged for a later admitted prompt without settling the receipt. * @param message - identified steering content and its producer provenance. + * @returns the receipt for this exact message's eventual admission outcome. */ - steer(message: UserMessage): void - - /** - * Atomically submit steering only while the current step still owns its final - * drain. Returns `false` without accepting the message during admission, - * between steps, or after the final per-step drain has begun. Cancellation or - * disposal may still discard previously accepted steering. - * @param message - identified steering content and its producer provenance. - * @returns whether the message entered the current step. - */ - trySteer?(message: UserMessage): boolean + steer(message: UserMessage): SteeringReceipt /** * Append model-facing context without running the model — the diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index b746073424..acd0dea472 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -10,7 +10,7 @@ Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/sub ## Two kinds of capability, discovered two ways -A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features are instead optional methods whose presence IS the capability, with TS narrowing as the discovery mechanism: strict live steering is [`SubagentRun.steer`](#a-live-run-subagentrun) and persisted cold resume is [`SubagentProvider.resume`](#the-provider-seam-subagentprovider). +A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features are instead optional methods whose presence IS the capability, with TS narrowing as the discovery mechanism: confirmed live steering is [`SubagentRun.steer`](#a-live-run-subagentrun) and persisted cold resume is [`SubagentProvider.resume`](#the-provider-seam-subagentprovider). ```ts type-equiv /** @@ -18,7 +18,7 @@ A provider advertises its **start-time** features on a static descriptor the ser * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent * degradation" rule). These static flags cover features needed before a run exists; runtime - * capabilities are optional methods whose presence is the capability — strict live steering + * capabilities are optional methods whose presence is the capability — confirmed live steering * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to * `maxDepth`; the other names match. @@ -214,7 +214,7 @@ interface SubagentStopReasonMap { ## A live run: `SubagentRun` -`SubagentRun` is the consumer-owned handle for a ready child — one disposable activation, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. A completed continuable result additionally means the provider confirmed the activation's final state durable; a failed required checkpoint rejects. The optional strict `steer` method advertises live delivery by presence. Cold resume is a provider-level operation: `SubagentProvider.resume` reconstructs a fresh run from the child's persisted session because the process-local run ceases to exist after disposal or process restart. +`SubagentRun` is the consumer-owned handle for a ready child — one disposable activation, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. A completed continuable result additionally means the provider confirmed the activation's final state durable; a failed required checkpoint rejects. The optional confirmed `steer` method advertises live delivery by presence and fulfills only after a request snapshot admits the message. Cold resume is a provider-level operation: `SubagentProvider.resume` reconstructs a fresh run from the child's persisted session because the process-local run ceases to exist after disposal or process restart. ```ts type-equiv /** @@ -251,19 +251,16 @@ interface SubagentRun { */ dispose(): Promise /** - * OPTIONAL (strict live-steering capability): deliver additional content to - * the actively running child turn. STRICT means delivery joins the observed - * turn or fails — the implementation must synchronously verify, with no - * asynchronous boundary before delivery, that the child is running and its - * turn can still record the message, and must not fall back to a queue path - * that could start a new, untracked turn or silently drop the message after - * this run has settled. Throws when delivery cannot join the turn. A run - * represents one disposable activation, so it has no cold-resume operation; - * resuming a settled child goes through {@link SubagentProvider.resume}. - * `source` is retained on the child's logged steering message without - * changing its user role in model history. + * OPTIONAL (confirmed live-steering capability): submit additional content + * to the active child and fulfill only after a committed request snapshot + * admits it. Rejects when terminal policy, cancellation, disposal, or a lost + * settlement race prevents admission; it never falls through to a queued + * untracked turn or cold resume. A run represents one disposable activation, + * so resuming a settled child goes through {@link SubagentProvider.resume}. + * `source` is retained on the admitted steering message without changing its + * user role in model history. */ - steer?(content: ContentBlock[], source: MessageSource): void + steer?(content: ContentBlock[], source: MessageSource): Promise } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0b3e8ff6f2..7fc4f549f2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:274`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:457`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:321`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:370`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:396`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:415`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:356`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:383`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:430`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:327`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:317`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:402`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:421`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:450`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:389`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | 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 5c484484bb..34193d60d1 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): void;\n trySteer?(message: UserMessage): boolean;\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 export type InboxActionResult = 'applied' | 'not-found';\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 delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: 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 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 export type InboxActionResult = 'applied' | 'not-found';\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 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/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 022232cb28..2d126ff5f9 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -44,7 +44,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { acceptsNextStep: true, ctx: new Context(), followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index acdb7da8f7..1d94399184 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -100,7 +100,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { acceptsNextStep: true, ctx: new Context(), followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), updateInbox: () => 'not-found', inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index bedffbf90a..158d96f24d 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -179,7 +179,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { status: 'idle', acceptsNextStep: false, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 749ba48359..43818cd6b7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -889,8 +889,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Start a continuable background child: allocate its stable session id,\n * snapshot its durable descriptor, and register the initial activation\'s\n * Task. A synchronous validation failure (a non-JSON descriptor input,\n * missing persistence, Task preflight) throws without creating a Task; the\n * method otherwise returns both identities immediately, without waiting for\n * child publication or descriptor durability. Asynchronous startup failure\n * settles the returned Task as `failed` (or `killed` when cancelled) after\n * any published run is disposed, which can leave an unmaterialized child id\n * that later by-id operations report as unavailable.\n * @param spec - provider, Task label, and the delegation request.\n * @returns the stable child id and the initial activation\'s Task id.\n */', }, { - signature: 'sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult', - jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. A throw means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the user-role content to deliver.\n * @param source - caller-supplied attribution retained across either route.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */', + signature: 'async sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise', + jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. Rejection means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the user-role content to deliver.\n * @param source - caller-supplied attribution retained across either route.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */', }, ], }, @@ -1583,7 +1583,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Agent', - declaration: '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): void;\n trySteer?(message: UserMessage): boolean;\n inject(message: UserMessage): void;\n}', + declaration: '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}', }, { name: 'AgentCancelCause', @@ -2669,6 +2669,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SpillSource', declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}', }, + { + name: 'SteeringOutcome', + declaration: 'export type SteeringOutcome = {\n readonly status: \'admitted\';\n readonly turn: number;\n readonly step: number;\n} | {\n readonly status: \'rejected\';\n};', + }, + { + name: 'SteeringReceipt', + declaration: 'export interface SteeringReceipt {\n readonly outcome: Promise;\n}', + }, { name: 'StorageForms', declaration: 'export interface StorageForms {\n}', @@ -2703,7 +2711,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n steer?(content: ContentBlock[], source: MessageSource): void;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n steer?(content: ContentBlock[], source: MessageSource): Promise;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 71cf5b0ec8..6e9e63a22f 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/core/agent-loop/README.md -README.md: 79d2865073c89bd88a4d39fafacb5cf60f1fc10c -README.zh.md: 48c4f4900d25f524942abf53c1bc887e7d125cb3 +README.md: 1662b1076cc116888d048cb6af1be1c7ab8196f6 +README.zh.md: 2fca32a02fdd73961c912c988933e1cd1a1a5817 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 79d2865073..1662b1076c 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -57,6 +57,8 @@ The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are pa The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. `reserveTurnAdmission()` can synchronously hold that idle boundary for a standalone durable operation: accepted waking work has right of way, later sends keep their ordinary queue identity and FIFO position, release re-arms the same driver path, and `whenIdle()` waits for the reservation without making teardown await it. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. +`steer()` attaches a one-shot admission receipt to its exact accepted message. After `agent/step` and asynchronous prompt assembly succeed, the loop commits a stable pending batch as `steering/message`, snapshots derived history, and opens `step/start`; only then does each receipt resolve `admitted` with that turn and step. Later arrivals remain pending. Idle steering enters the ordinary FIFO and uses the first request of its eventual turn as the same admission boundary. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never reaches a request resolves affected receipts `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Open-turn `inject()` still commits after all tool results, including accepted context finalized during an interrupted batch, while steering remains provisional until a request admits it. + Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. ### Loop lifecycle (`agent.ts`) diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 48c4f4900d..2fca32a02f 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -57,6 +57,8 @@ interface Config { 统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。`reserveTurnAdmission()` 可以为独立持久操作同步保留该空闲边界:已获接纳的唤醒工作拥有优先权,之后发送的项保留普通队列身份与 FIFO 位置,释放会重新启用同一驱动器路径,`whenIdle()` 会等待预留结束,但 teardown 不会等待它。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。 +`steer()` 会把一次性准入回执附着到其准确的已接收消息。`agent/step` 和异步提示词组装成功后,循环把稳定的待处理批次提交为 `steering/message`、捕获派生历史并开启 `step/start`;只有此时,每个回执才会解析为 `admitted`,并附带轮次与步骤。之后到达的消息继续待处理。空闲 steering 会进入普通 FIFO,并以其最终轮次的首次请求作为相同准入边界。结束轮次的工具结果、广义取消、dispose(资源释放),或已领取 idle-steering 消息却从未到达请求的轮次,会把受影响回执解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。活跃轮次内的 `inject()` 仍会在所有工具结果后提交,包括被中断批次中已最终确认的上下文;steering 则保持待准入,直到请求接纳它。 + 每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 ### 循环生命周期(`agent.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 98c0fae6bc..b41dd69594 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -29,6 +29,8 @@ import type { RequestError, RequestErrorAction, SendOptions, + SteeringOutcome, + SteeringReceipt, } from '@deepseek-ai/dsh-agent' import { BlockAssembler, @@ -56,6 +58,26 @@ type StepOutcome = | { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean } | { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined } +/** Internal one-shot controller paired with a public steering receipt. */ +interface SteeringDelivery { + readonly receipt: SteeringReceipt + settle(outcome: SteeringOutcome): void +} + +/** Create one idempotent steering-admission controller. */ +function createSteeringDelivery(): SteeringDelivery { + const { promise, resolve } = Promise.withResolvers() + let settled = false + return { + receipt: { outcome: promise }, + settle(outcome): void { + if (settled) return + settled = true + resolve(outcome) + }, + } +} + const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt' /** Clearing marker kept distinct from every prefixed {@link renderContextSnapshot} result. */ const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.' @@ -112,9 +134,13 @@ function requestProposal(header: EpochHeader): LlmCallConfig { */ export class ReactLoopAgent implements Agent { /** Prompts awaiting individual turns. */ - private queued: { item: InboxItem; wakeup: boolean }[] = [] + private queued: { item: InboxItem; wakeup: boolean; delivery?: SteeringDelivery }[] = [] /** Input taken into the session log at step boundaries. */ - private outbox: { message: UserMessage; steering: boolean; item?: InboxItem }[] = [] + private outbox: { message: UserMessage; steering: boolean; item?: InboxItem; delivery?: SteeringDelivery }[] = [] + /** Steering already committed to the log but not yet captured by a request. */ + private pendingAdmissions: SteeringDelivery[] = [] + /** Whether the active cancellation preserves already committed pending delivery. */ + private preservePendingAdmissionsOnAbort = false /** Whether observers see a running interval; consecutive turns share it. */ private busy = false @@ -142,8 +168,6 @@ export class ReactLoopAgent implements Agent { /** Whether the session log is owed a matching turn end event. */ private turnOpen = false private stepOpen = false - /** Whether {@link trySteer} can still join the current step's final drain. */ - private strictSteeringOpen = false /** Whether this loop instance has appended its initial/resume request anchor. */ private requestHeaderLogged = false @@ -167,6 +191,15 @@ export class ReactLoopAgent implements Agent { send( message: UserMessage, options: SendOptions, + ): void { + this.route(message, options) + } + + /** Route one accepted message, optionally tracking steering admission. */ + private route( + message: UserMessage, + options: SendOptions, + delivery?: SteeringDelivery, ): void { const { target, wakeup } = options if (target === 'next-step' && !wakeup) { @@ -185,9 +218,9 @@ export class ReactLoopAgent implements Agent { placement, }) if (placement === 'steering') { - this.outbox.push({ message, steering: true, item }) + this.outbox.push({ message, steering: true, item, ...delivery === undefined ? {} : { delivery } }) } else { - this.queued.push({ item, wakeup }) + this.queued.push({ item, wakeup, ...delivery === undefined ? {} : { delivery } }) } // Preserve the routing decision for every send in this synchronous caller // stack, while installing quiescence ownership before enqueue observers @@ -218,6 +251,7 @@ export class ReactLoopAgent implements Agent { } case 'remove': { this.queued.splice(queuedIndex, 1) + pending.delivery?.settle({ status: 'rejected' }) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item]) return 'applied' } @@ -235,22 +269,14 @@ export class ReactLoopAgent implements Agent { }) } - /** Steer the open turn, falling back to a waking prompt while idle. */ - steer(input: UserMessage): void { - this.send(input, { + /** Steer the open turn, falling back to a tracked waking prompt while idle. */ + steer(input: UserMessage): SteeringReceipt { + const delivery = createSteeringDelivery() + this.route(input, { target: 'next-step', wakeup: true, - }) - } - - /** Atomically steer only while the current step still owns its final drain. */ - trySteer(input: UserMessage): boolean { - if (!this.strictSteeringOpen) return false - this.send(input, { - target: 'next-step', - wakeup: true, - }) - return true + }, delivery) + return delivery.receipt } /** Append model-facing context without waking the driver. */ @@ -304,11 +330,17 @@ export class ReactLoopAgent implements Agent { // inboxes clear; listener failures are contained by the dispatcher. if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) } + if (options.keepInbox && this.abort !== undefined) this.preservePendingAdmissionsOnAbort = true if (!options.keepInbox) { const discarded = this.queued.map(item => item.item) + for (const item of this.queued) item.delivery?.settle({ status: 'rejected' }) for (const item of this.outbox) { - if (item.steering && item.item !== undefined) discarded.push(item.item) + if (item.steering && item.item !== undefined) { + item.delivery?.settle({ status: 'rejected' }) + discarded.push(item.item) + } } + this.rejectPendingAdmissions() // Clear before abort observers run: replacement work belongs to the next turn. this.queued.length = 0 this.outbox.length = 0 @@ -373,7 +405,8 @@ export class ReactLoopAgent implements Agent { // The some() guard above proves the queue is non-empty; the non-null // assertion expresses that invariant. // oxlint-disable-next-line typescript/no-non-null-assertion - const { item } = this.queued.shift()! + const pending = this.queued.shift()! + const { item, delivery } = pending const { message } = item const inheritedOutboxLength = this.outbox.length @@ -423,6 +456,7 @@ export class ReactLoopAgent implements Agent { // still owns the slot here and releasing it unconditionally is exact. this.abort = undefined if (admitted === undefined) { + delivery?.settle({ status: 'rejected' }) this.acceptsNextStep = false try { this.flushRejectedAdmissionContexts() @@ -440,7 +474,7 @@ export class ReactLoopAgent implements Agent { this.continueOrIdle() return } - await this.run(trigger, admitted, inheritedOutboxLength) + await this.run(trigger, admitted, inheritedOutboxLength, Object.freeze([]), delivery) }) // Published only after the abort owner and pending done are installed: a // dequeue listener that cancels or disposes must find live cancellation @@ -457,6 +491,7 @@ export class ReactLoopAgent implements Agent { admitted: UserMessage[] = [], inheritedOutboxLength = 0, priorFailures: readonly LlmFailure[] = Object.freeze([]), + promptDelivery?: SteeringDelivery, ): Promise { // Both entries hold the invariant: kick() clears the admission slot before // awaiting run(), and a retry is entered only after the prior run clears it. @@ -464,6 +499,7 @@ export class ReactLoopAgent implements Agent { if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`) const controller = new AbortController() this.abort = controller + this.preservePendingAdmissionsOnAbort = false this.acceptsNextStep = true const signal = controller.signal const turn = this.lastTurn + 1 @@ -487,13 +523,12 @@ export class ReactLoopAgent implements Agent { // Context or steering retained by an earlier rejected admission happened // before this prompt and must occupy the same order in durable history. this.drainOutbox(turn, inheritedOutboxLength) + if (promptDelivery !== undefined) this.pendingAdmissions.push(promptDelivery) for (const input of admitted) { this.session.append('user/message', input, { surfaceOp: 'append' }) } signal.throwIfAborted() - this.drainOutbox(turn) - steps: while (true) { step += 1 const outcome = await this.step(turn, step, signal) @@ -501,17 +536,19 @@ export class ReactLoopAgent implements Agent { case 'completed': requestFailureHistory = Object.freeze([]) if (outcome.maxTokens) reason = { kind: 'max-tokens' } - // A concluding tool result is terminal: steering already in the - // log waits for the next turn's request instead of reopening this - // one, and the agent/turn-stopping drain below is skipped for the same - // reason. - if (outcome.concluded) break steps + // A concluding tool result is terminal: reject steering that did + // not enter a request, while retaining same-boundary context in + // durable history before the turn closes. + if (outcome.concluded) { + this.discardOutboxSteering() + this.drainOutbox(turn) + break steps + } if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue break case 'request-failed': { // step() reports request failures only after step/start commits // and before its own step/end, so the step is always open here. - this.strictSteeringOpen = false this.stepOpen = false this.session.append('step/end', { turn, step }) if (!signal.aborted) { @@ -542,12 +579,14 @@ export class ReactLoopAgent implements Agent { } await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) signal.throwIfAborted() - if (!this.drainOutbox(turn)) break + this.drainOutboxContexts() + if (!this.outbox.some(item => item.steering)) { + break + } } } catch (caught: unknown) { try { if (this.stepOpen) { - this.strictSteeringOpen = false this.stepOpen = false this.session.append('step/end', { turn, step }) } @@ -565,7 +604,6 @@ export class ReactLoopAgent implements Agent { // failure paths (step(), the request-failed branch, the catch), so the // finally owes only the turn boundary. this.acceptsNextStep = false - this.strictSteeringOpen = false try { if (this.turnOpen) { // Re-entrant turn/end listeners must route new input to a later turn. @@ -582,6 +620,10 @@ export class ReactLoopAgent implements Agent { // is still this run's controller here. this.abort = undefined signal.removeEventListener('abort', cancelRetry) + const preservePending = signal.aborted && this.preservePendingAdmissionsOnAbort + this.preservePendingAdmissionsOnAbort = false + // oxlint-disable-next-line typescript/no-unnecessary-condition -- keepInbox cancellation can set this while turn work is awaited. + if (!preservePending) this.rejectPendingAdmissions() } if (opened) { @@ -620,10 +662,6 @@ export class ReactLoopAgent implements Agent { await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal) signal.throwIfAborted() - // Take the outbox whole — same-boundary steering and context leave in - // this request together. - this.drainOutbox(turn) - // Assemble request-owned prompt inputs fresh each step. Dynamic context is // committed at the tail before deriving history once, preserving the stable // system/history cache prefix while keeping every model-visible byte logged. @@ -632,13 +670,18 @@ export class ReactLoopAgent implements Agent { const system = renderPrompt(assembly) materializeRuntimeContext(session, renderContextSnapshot(assembly)) + // Commit the exact pending batch only after every asynchronous + // pre-request contribution succeeded. Input accepted after this splice + // remains pending for a later request. + this.drainOutbox(turn) + // Snapshot the exact log prefix: the reconstruction boundary. Appends // after this synchronous snapshot join the next request. const boundaryMessages = session.deriveMessages() session.append('step/start', { turn, step }) this.stepOpen = true - this.strictSteeringOpen = true + this.admitPendingAdmissions(turn, step) signal.throwIfAborted() const { request, preparedCall } = await this.buildRequest( @@ -705,15 +748,14 @@ export class ReactLoopAgent implements Agent { )) } - // Tool results stay adjacent to their calls; input accepted during the - // request enters the log only after the complete result batch. - this.strictSteeringOpen = false - const steered = this.drainOutbox(turn) + // Ordinary context keeps the base loop's result-adjacent commit point. + // Steering remains provisional until the next request snapshot admits it. + this.drainOutboxContexts() session.append('step/end', { turn, step }) this.stepOpen = false return { kind: 'completed', - continueTurn: (toolCalls.length > 0 && !concluded) || steered, + continueTurn: (toolCalls.length > 0 && !concluded) || this.outbox.some(item => item.steering), concluded, maxTokens: finish.kind === 'max-tokens', } @@ -818,25 +860,83 @@ export class ReactLoopAgent implements Agent { return { request, ...preparedCall === undefined ? {} : { preparedCall } } } - /** Commit the outbox and report whether it contained steering. */ - private drainOutbox(turn: number, limit = this.outbox.length): boolean { - let steered = false - for (const item of this.outbox.splice(0, limit)) { - if (item.steering) { - steered = true - /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */ - if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item) - this.session.append( - 'steering/message', - { turn, message: item.message }, - { surfaceOp: 'append' }, - ) - } else { - this.session.append('user/message', item.message, { surfaceOp: 'append' }) + /** Commit one stable outbox prefix and retain tracked delivery until snapshot admission. */ + private drainOutbox(turn: number, limit = this.outbox.length): void { + const batch = this.outbox.splice(0, limit) + for (let index = 0; index < batch.length; index += 1) { + const item = batch[index] + /* v8 ignore next -- the index walks the exact array length. */ + if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during drain`) + try { + if (item.steering) { + /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */ + if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item) + this.session.append( + 'steering/message', + { turn, message: item.message }, + { surfaceOp: 'append' }, + ) + if (item.delivery !== undefined) this.pendingAdmissions.push(item.delivery) + } else { + this.session.append('user/message', item.message, { surfaceOp: 'append' }) + } + } catch (error: unknown) { + item.delivery?.settle({ status: 'rejected' }) + this.outbox.unshift(...batch.slice(item.steering ? index + 1 : index)) + throw error } } - return steered + } + + /** Commit ordinary context while retaining provisional steering in order. */ + private drainOutboxContexts(): void { + const pending = this.outbox + this.outbox = [] + for (let index = 0; index < pending.length; index += 1) { + const item = pending[index] + /* v8 ignore next -- the index walks the exact array length. */ + if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during context drain`) + if (item.steering) { + this.outbox.push(item) + continue + } + try { + this.session.append('user/message', item.message, { surfaceOp: 'append' }) + } catch (error: unknown) { + this.outbox.push(...pending.slice(index)) + throw error + } + } + } + + /** Settle every committed steering item captured by this immutable request. */ + private admitPendingAdmissions(turn: number, step: number): void { + const outcome: SteeringOutcome = { status: 'admitted', turn, step } + for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle(outcome) + } + + /** Reject committed steering that left the inbox without reaching a request. */ + private rejectPendingAdmissions(): void { + for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle({ status: 'rejected' }) + } + + /** Discard uncommitted steering while retaining same-boundary injected context. */ + private discardOutboxSteering(): void { + const contexts: typeof this.outbox = [] + const discarded: InboxItem[] = [] + for (const item of this.outbox) { + if (!item.steering) { + contexts.push(item) + continue + } + item.delivery?.settle({ status: 'rejected' }) + /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */ + if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) + discarded.push(item.item) + } + this.outbox = contexts + if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded) } /** diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 295117d7d3..dfa0a91098 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -50,10 +50,11 @@ describe('Agent', () => { }])).toBeUndefined() expect(call('inject', [message('context')])).toBeUndefined() expect(call('followup', [message('followup')])).toBeUndefined() - expect(call('steer', [message('steering')])).toBeUndefined() + const receipt = agent.steer(message('steering')) await agent.whenIdle() expect(adapter.requests).toHaveLength(3) + expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 3, step: 1 }) }) it('idle inject() appends context without opening a turn or requesting a flush', async () => { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index a362db8552..725e3db36a 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -721,13 +721,14 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + let receipt: ReturnType | undefined ctx.tools.register(defineContentToolFixture({ name: 'finalize', description: '', parameters: {}, async execute(_args, exec) { // Steering lands while the concluding tool is still executing. - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } })) + receipt = agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } })) exec.concludeTurn() return [{ type: 'text', text: 'final' }] }, @@ -740,9 +741,9 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) const events = agent.session.events.map(event => event.type) expect(events.filter(type => type === 'turn/end')).toHaveLength(1) - // The steering is durable inside the concluded turn and feeds the NEXT - // turn's request instead of being dropped or re-queued. - expect(events).toContain('steering/message') + if (receipt === undefined) throw new Error('concluding tool did not submit steering') + expect(await receipt.outcome).toEqual({ status: 'rejected' }) + expect(events).not.toContain('steering/message') send(agent, 'follow up') await waitForIdle(ctx, agent) @@ -751,7 +752,7 @@ describe('agent loop', () => { .flatMap(message => message.content) .filter(block => block.type === 'text') .map(block => block.text) - expect(texts).toContain('late steering') + expect(texts).not.toContain('late steering') }) it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 6026c917c6..8a60283521 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -64,8 +64,7 @@ The handle every plugin programs against: - `agent.reserveTurnAdmission()` — synchronously reserve the idle boundary before any queued waking prompt can claim its turn. An accepted prompt, including a same-tick pending wake, has right of way and makes reservation return `undefined`. Later sends keep their ordinary IDs, FIFO placement, and wakeup facts while held; `acceptsNextStep` remains false, `inject()` is not withheld, `whenIdle()` counts the reservation as activity, and the returned release is idempotent. This narrow coordination capability lets standalone durable operations such as manual compaction finish and flush before queued prompts derive from the session. - `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`. - `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. -- `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it. -- `agent.trySteer?(input)` — an optional strict-steering capability implemented by the default loop. It atomically submits an identified message only while the current step still owns its final drain, returning `false` without accepting input during admission, between steps, or after that drain begins; cancellation and disposal can still discard accepted steering. +- `agent.steer(input)` — the `next-step`/wakeup preset: submit one identified message and receive its `SteeringReceipt`. During prompt admission or an open turn, the message stages for the next safe request boundary without dispatching `agent/prompt-submit`; outside that acceptance window, it becomes a woken queued prompt. `receipt.outcome` resolves `admitted` with the turn and step only after the loop logs the message, captures it in immutable request history, and commits `step/start`. A turn-concluding tool result, broad cancellation, disposal, or pre-admission failure resolves it `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Reliable callers await the receipt, while best-effort UI steering may ignore it. - `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. - `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement. - `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 851c174ba8..ffa71ea987 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -64,7 +64,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, - `agent.reserveTurnAdmission()`:在任何已排队唤醒提示词认领其轮次之前,同步预留空闲边界。已获接纳的提示词拥有优先权,包括同一 tick 内仍在等待唤醒的项,此时预留返回 `undefined`。预留期间,之后发送的项保留其普通 ID、FIFO 位置与唤醒信息;`acceptsNextStep` 保持 false,`inject()` 不受阻塞,`whenIdle()` 将该预留计为活动,返回的释放函数可幂等调用。这项范围有限的协调能力使手动压缩(compaction)等独立持久操作能够在排队提示词从会话派生内容前完成并 flush。 - `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`。 - `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。 -- `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。 +- `agent.steer(input)`:`next-step`/wakeup 预设:提交一条已有标识的消息,并取得其 `SteeringReceipt`。提示词接纳期间或轮次打开时,消息会为下一个安全请求边界暂存,且不分发 `agent/prompt-submit`;该接收窗口之外则成为会唤醒驱动器的排队提示词。只有循环记录消息、将其捕获到不可变请求历史并提交 `step/start` 后,`receipt.outcome` 才会解析为 `admitted`,并附带轮次与步骤。结束轮次的工具结果、广义取消、dispose(资源释放)或准入前故障会使其解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。需要可靠投递的调用方应等待回执;尽力执行的 UI steering 可以忽略它。 - `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。 - `agent.acceptsNextStep`:当前发送 `next-step` 时,是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。 - `agent.cancel(cause, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作。调用方必须显式选择 `user | parent` 原因;活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时,首个信号生效;空闲取消是安全空操作,不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`。 diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 1acf8c5ae5..1a1010e90e 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -58,6 +58,20 @@ export type InboxAction = /** Result of applying an inbox action at the synchronous ownership boundary. */ export type InboxActionResult = 'applied' | 'not-found' +/** Final admission outcome for one call to {@link Agent.steer}. */ +export type SteeringOutcome = + | { readonly status: 'admitted'; readonly turn: number; readonly step: number } + | { readonly status: 'rejected' } + +/** + * Message-owned steering admission receipt. The outcome promise always + * resolves: synchronous input validation still throws from {@link Agent.steer}, + * while lifecycle policy reports non-admission as `rejected`. + */ +export interface SteeringReceipt { + readonly outcome: Promise +} + /** * Options for the unified {@link Agent.send} primitive over the * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} @@ -225,26 +239,18 @@ export interface Agent { followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn — the - * `next-step`/wakeup preset of {@link send}. It stages for the next steering - * checkpoint before a request or stop decision. If the activity fails before - * that boundary, the remainder stays staged without waking the agent; retry - * or a later prompt takes it. Outside that window steering falls back to a - * woken follow-up turn, while cancellation or disposal may discard pending - * steering. + * Submit steering with a message-owned admission receipt — the + * `next-step`/wakeup preset of {@link send}. During prompt admission or an + * open turn, the message waits in the steering FIFO until a committed step + * snapshots it; outside that window it enters the ordinary queued FIFO. The + * receipt resolves `admitted` only after the message joins that step's + * immutable request history, or `rejected` when terminal policy, + * cancellation, or disposal discards it first. A non-terminal turn close may + * leave it staged for a later admitted prompt without settling the receipt. * @param message - identified steering content and its producer provenance. + * @returns the receipt for this exact message's eventual admission outcome. */ - steer(message: UserMessage): void - - /** - * Atomically submit steering only while the current step still owns its final - * drain. Returns `false` without accepting the message during admission, - * between steps, or after the final per-step drain has begun. Cancellation or - * disposal may still discard previously accepted steering. - * @param message - identified steering content and its producer provenance. - * @returns whether the message entered the current step. - */ - trySteer?(message: UserMessage): boolean + steer(message: UserMessage): SteeringReceipt /** * Append model-facing context without running the model — the diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 8d586313b6..09f3af6cff 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -26,7 +26,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, reserveTurnAdmission: () => undefined, cancel() {}, diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 797263897f..1800b1c3fd 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -36,7 +36,7 @@ function agent(ctx: Context, cwd: string): Agent { acceptsNextStep: false, ctx: scope.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1da1e0da48..31d7f5c8d3 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -40,7 +40,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { appendInjection(session, input) }, reserveTurnAdmission: () => undefined, cancel() { status = 'idle' }, diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 82793618c4..c7886fac0f 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -50,7 +50,7 @@ function stubAgentForSession(session: Session): StubAgent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { if (shouldDefer) deferred.push(input) else appendInjection(session, input) diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 308395a0a1..7b12d76f59 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -41,7 +41,7 @@ function liveAgent(ctx: Context, session: Session): Agent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input: UserMessage) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index e68b9f73bb..4278fc5a50 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -35,7 +35,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index da05a4cd9b..c59dcf7e64 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -48,7 +48,7 @@ function stubAgent(session: Session): Agent { acceptsNextStep: false, ctx: new Context(), followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index f04fff74ba..d9d8aa16d1 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -45,7 +45,7 @@ function agent(ctx: Context, cwd?: string): Agent { options: {}, session: new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }), status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -258,7 +258,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -301,7 +301,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 46c4f1f6c9..6fa7804980 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 08bd39c28a..0b91d5a88c 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -29,7 +29,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { acceptsNextStep: false, ctx: scopeFiber.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index cca0554595..b1bc368ab2 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -46,7 +46,7 @@ function agent(ctx: Context, cwd: string): Agent { acceptsNextStep: false, ctx: scope.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 2c767de795..10f2b369a2 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { acceptsNextStep: false, ctx: scope.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index d5164261c7..d6b4a08968 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index bef549d483..a25f97ccd0 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index ce92c12b39..df8f4e6fed 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -49,7 +49,7 @@ function agentForCwd(cwd: string): Agent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, @@ -70,7 +70,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/subagent/subagent-control/README.md b/packages/subagent/subagent-control/README.md index 141333f719..d1afd86687 100644 --- a/packages/subagent/subagent-control/README.md +++ b/packages/subagent/subagent-control/README.md @@ -6,7 +6,7 @@ The continuable-subagent control service (`ctx.subagentControl`): the one orches A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output. -`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. +`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's confirmed `steer` capability and returns the existing Task id (`steered`) only after a committed request snapshot admits the message; an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Rejection means the message was not delivered: terminal policy or Task settlement winning the admission race never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface). diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts index 9de5459451..e8a609e2d6 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent-control/src/index.ts @@ -251,7 +251,7 @@ export class SubagentControlService extends Service { * Deliver one message to a known continuable child: steer its running * activation, or cold-resume the durable session into a fresh Task-backed * activation. The two routes are reported distinctly so timing-dependent - * routing is observable. A throw means the message was NOT delivered — in + * routing is observable. Rejection means the message was NOT delivered — in * particular, losing a race with Task settlement does not fall through to * cold resume within the same call; a later retry after Task terminal may * start the next activation. The started Task owns descriptor lookup and @@ -265,13 +265,18 @@ export class SubagentControlService extends Service { * @param source - caller-supplied attribution retained across either route. * @returns whether the message `steered` the existing Task or `started` a new one. */ - sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult { + async sendMessage( + parent: Agent, + childId: SessionId, + message: ContentBlock[], + source: MessageSource, + ): Promise { this.assertOwnership(childId) const activation = this.activations.get(childId) if (activation !== undefined) { return { route: 'steered', - taskId: this.steerActivation(activation, parent, childId, message, source), + taskId: await this.steerActivation(activation, parent, childId, message, source), } } return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) } @@ -301,20 +306,20 @@ export class SubagentControlService extends Service { } } - /** Deliver to the running activation's Task through strict live steering. */ - private steerActivation( + /** Deliver to the running activation's Task through confirmed live steering. */ + private async steerActivation( activation: ActiveActivation, parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, - ): TaskId { + ): Promise { const taskId = activation.taskId /* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */ if (taskId === undefined) { throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') } - // Owner-session authorization plus the live status for the strict check. + // Owner-session authorization plus the live status for admission. const snapshot = this.ctx.tasks.get(taskId, parent) if (snapshot.status !== 'running') { throw new SubagentControlError( @@ -334,9 +339,9 @@ export class SubagentControlService extends Service { ) } try { - run.steer(message, source) + await run.steer(message, source) } catch (error: unknown) { - // Strict steering lost the race with turn settlement. Deliberately no + // Confirmed steering lost the race with request admission. Deliberately no // cold-resume fallback here: that would attach the message to a turn the // caller did not observe. throw new SubagentControlError( diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index c81b88b756..a1c12228d7 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -17,7 +17,7 @@ import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm' -import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -30,11 +30,14 @@ interface GatedEntry { /** Adapter whose entries can hold a model call open until the test releases it. */ class GatedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + constructor(private script: GatedEntry[]) { super() } async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) const entry = this.script.shift() if (!entry) throw new Error('GatedAdapter: script exhausted') if (entry.gate) await entry.gate @@ -216,7 +219,7 @@ describe('SubagentControlService.startContinuable', () => { expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain('maxDepth') // The unmaterialized child id is reported unavailable on later use. - const followUp = sendMessage(ctx, parent, started.childId, message('hello?')) + const followUp = await sendMessage(ctx, parent, started.childId, message('hello?')) expect(followUp.route).toBe('started') const failed = await waitTerminal(ctx, followUp.taskId, parent) expect(failed.status).toBe('failed') @@ -264,20 +267,23 @@ describe('SubagentControlService.sendMessage', () => { await waitPublishedRun(ctx, started.childId) expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) - expect(() => sendMessage(ctx, parent, started.childId, message('join'))) - .toThrow(/provider does not accept live delivery/) + await expect(sendMessage(ctx, parent, started.childId, message('join'))) + .rejects.toThrow(/provider does not accept live delivery/) let terminalDeliveryError: unknown + let terminalDelivery: Promise | undefined ctx.tasks.onTaskDone((snapshot) => { if (snapshot.id !== started.taskId) return - try { - sendMessage(ctx, parent, started.childId, message('after terminal')) - } catch (error: unknown) { - terminalDeliveryError = error - } + terminalDelivery = sendMessage(ctx, parent, started.childId, message('after terminal')).then( + () => undefined, + (error: unknown) => { + terminalDeliveryError = error + }, + ) }) result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) await waitTerminal(ctx, started.taskId, parent) + await terminalDelivery expect(String(terminalDeliveryError)).toContain('is completed') }) @@ -310,8 +316,8 @@ describe('SubagentControlService.sendMessage', () => { const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local')) await waitPublishedRun(ctx, started.childId) - expect(() => sendMessage(ctx, parent, started.childId, message('join'))) - .toThrow(/registry agent is not the associated activation's agent/) + await expect(sendMessage(ctx, parent, started.childId, message('join'))) + .rejects.toThrow(/registry agent is not the associated activation's agent/) result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) await waitTerminal(ctx, started.taskId, parent) }) @@ -322,30 +328,32 @@ describe('SubagentControlService.sendMessage', () => { // second step in the SAME turn. let releaseFirst!: () => void const gate = new Promise((resolve) => { releaseFirst = resolve }) - const { ctx, parent } = await setupWith(new GatedAdapter([ + const adapter = new GatedAdapter([ { chunks: textResponse('first step answer'), gate }, { chunks: textResponse('steered turn answer') }, - ])) + ]) + const { ctx, parent } = await setupWith(adapter) const started = ctx.subagentControl.startContinuable(startSpec(parent)) - // Wait for the child agent to publish and enter running. + // Wait until the first immutable request has crossed the adapter boundary. await new Promise((resolve) => { const timer = setInterval(() => { - if (ctx.agents.get(started.childId)?.status === 'running') { + if (adapter.requests.length === 1) { clearInterval(timer) resolve() } }, 5) }) - const delivered = ctx.subagentControl.sendMessage( + const delivery = ctx.subagentControl.sendMessage( parent, started.childId, message('also consider Y'), coordinatorSource, ) - expect(delivered).toEqual({ route: 'steered', taskId: started.taskId }) releaseFirst() + const delivered = await delivery + expect(delivered).toEqual({ route: 'steered', taskId: started.taskId }) const snapshot = await waitTerminal(ctx, started.taskId, parent) expect(snapshot.status).toBe('completed') // Exactly one Task exists: steering created none. @@ -360,13 +368,57 @@ describe('SubagentControlService.sendMessage', () => { expect(steering?.data.message.source).toEqual(coordinatorSource) }) + it('rejects before acknowledgement when terminal policy prevents steering admission', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', 'structured_output', { answer: 7 }), + ]) + const startedTool = Promise.withResolvers() + const releaseTool = Promise.withResolvers() + ctx.on('tools/pre-execute', async (exec, next) => { + if (exec.name === 'structured_output') { + startedTool.resolve(undefined) + await releaseTool.promise + } + return next() + }) + + const base = startSpec(parent) + const started = ctx.subagentControl.startContinuable({ + ...base, + request: { + ...base.request, + outputSchema: { + type: 'object', + properties: { answer: { type: 'number' } }, + required: ['answer'], + }, + }, + }) + await startedTool.promise + + const delivery = ctx.subagentControl.sendMessage( + parent, + started.childId, + message('follow-up that terminal policy rejects'), + coordinatorSource, + ) + releaseTool.resolve(undefined) + await expect(delivery).rejects.toThrow(/message was not delivered/) + + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('completed') + expect(adapter.requests).toHaveLength(1) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) + }) + it('cold-resumes a settled child into a fresh Task and reports `started`', async () => { const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) const started = ctx.subagentControl.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) expect(ctx.agents.get(started.childId)).toBeUndefined() - const followUp = ctx.subagentControl.sendMessage( + const followUp = await ctx.subagentControl.sendMessage( parent, started.childId, message('and then?'), @@ -409,7 +461,7 @@ describe('SubagentControlService.sendMessage', () => { expect(descriptor?.data.persona).toBe('You are the resumable child.') expect(descriptor?.data.toolFilter).toEqual({ deny: [] }) - const followUp = sendMessage(ctx, parent, started.childId, message('continue')) + const followUp = await sendMessage(ctx, parent, started.childId, message('continue')) const snapshot = await waitTerminal(ctx, followUp.taskId, parent) expect(snapshot.status).toBe('completed') // The resumed child's system prompt carried the persona back. @@ -438,7 +490,7 @@ describe('SubagentControlService.sendMessage', () => { parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } })) await parent.whenIdle() - const followUp = sendMessage(ctx, parent, started.childId, message('follow up')) + const followUp = await sendMessage(ctx, parent, started.childId, message('follow up')) await waitTerminal(ctx, followUp.taskId, parent) const resumed = await ctx.sessionPersistence.load(started.childId) // The persisted seed boundary is unchanged and parent turn two is absent. @@ -454,7 +506,7 @@ describe('SubagentControlService.sendMessage', () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) const started = ctx.subagentControl.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) - const followUp = sendMessage(ctx, parent, started.childId, message('go on')) + const followUp = await sendMessage(ctx, parent, started.childId, message('go on')) const childAgents: Agent[] = [] const stop = ctx.on('agent/created', (agent: Agent) => { @@ -474,7 +526,7 @@ describe('SubagentControlService.sendMessage', () => { const started = ctx.subagentControl.startContinuable(startSpec(otherParent)) await waitTerminal(ctx, started.taskId, otherParent) - const attempt = sendMessage(ctx, parent, started.childId, message('mine now')) + const attempt = await sendMessage(ctx, parent, started.childId, message('mine now')) expect(attempt.route).toBe('started') const snapshot = await waitTerminal(ctx, attempt.taskId, parent) expect(snapshot.status).toBe('failed') @@ -493,7 +545,7 @@ describe('SubagentControlService.sendMessage', () => { await handle.agent.whenIdle() await handle.dispose() - const attempt = sendMessage(ctx, parent, SessionId('plain-child'), message('continue?')) + const attempt = await sendMessage(ctx, parent, SessionId('plain-child'), message('continue?')) const snapshot = await waitTerminal(ctx, attempt.taskId, parent) expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain( @@ -503,9 +555,9 @@ describe('SubagentControlService.sendMessage', () => { it('derives fallback and bounded labels for resumed activations', async () => { const { ctx, parent } = await setup([]) - const blank = sendMessage(ctx, parent, SessionId('blank-child'), message(' ')) + const blank = await sendMessage(ctx, parent, SessionId('blank-child'), message(' ')) const longText = 'x'.repeat(100) - const long = sendMessage(ctx, parent, SessionId('long-child'), message(longText)) + const long = await sendMessage(ctx, parent, SessionId('long-child'), message(longText)) expect(ctx.tasks.get(blank.taskId, parent).label).toBe('subagent follow-up') expect(ctx.tasks.get(long.taskId, parent).label).toBe(`${'x'.repeat(79)}…`) @@ -523,14 +575,14 @@ describe('SubagentControlService.sendMessage', () => { meta: { parentSession: parent.id }, agentOptions: { provider: 'mock', model: 'mock' }, }) - expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) - .toThrow(SubagentControlError) - expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) - .toThrow(/outside control-service ownership.*not delivered/) + await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) + .rejects.toThrow(SubagentControlError) + await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) + .rejects.toThrow(/outside control-service ownership.*not delivered/) await handle.dispose() }) - it('does not fall through to cold resume when strict steering loses the settlement race', async () => { + it('does not fall through to cold resume when steering loses the admission race', async () => { // Deterministic race: hold run disposal open so the association still // names a run whose child turn has already ended. const { ctx, parent } = await setup([textResponse('quick answer'), textResponse('unused')]) @@ -564,15 +616,15 @@ describe('SubagentControlService.sendMessage', () => { }, 5) }) - // Strict steering finds the settled child, fails loud, and does NOT start + // Confirmed steering finds the settled child, fails loud, and does NOT start // a cold resume within this call. - expect(() => sendMessage(ctx, parent, started.childId, message('too late?'))) - .toThrow(/not delivered/) + await expect(sendMessage(ctx, parent, started.childId, message('too late?'))) + .rejects.toThrow(/not delivered/) expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId]) releaseDispose() await waitTerminal(ctx, started.taskId, parent) // AFTER the Task settles, retry legitimately starts the next activation. - const retry = sendMessage(ctx, parent, started.childId, message('retry')) + const retry = await sendMessage(ctx, parent, started.childId, message('retry')) expect(retry.route).toBe('started') await waitTerminal(ctx, retry.taskId, parent) }) @@ -581,7 +633,7 @@ describe('SubagentControlService.sendMessage', () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) const started = ctx.subagentControl.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) - const followUp = sendMessage(ctx, parent, started.childId, message('more')) + const followUp = await sendMessage(ctx, parent, started.childId, message('more')) const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' }) expect(() => ctx.tasks.get(followUp.taskId, other)).toThrow(/belongs to another session/) }) @@ -600,7 +652,7 @@ describe('SubagentControlService.sendMessage', () => { return realLoad(id) } - const followUp = sendMessage(ctx, parent, started.childId, message('follow up')) + const followUp = await sendMessage(ctx, parent, started.childId, message('follow up')) expect(ctx.tasks.kill(followUp.taskId, parent)).toBe('requested') releaseLoad() const snapshot = await waitTerminal(ctx, followUp.taskId, parent) @@ -622,12 +674,12 @@ describe('SubagentControlService.sendMessage', () => { return realLoad(id) } - const first = sendMessage(ctx, parent, started.childId, message('first follow-up')) + const first = await sendMessage(ctx, parent, started.childId, message('first follow-up')) expect(first.route).toBe('started') // The association is installed synchronously, so the competing caller // observes the pending activation instead of starting a duplicate resume. - expect(() => sendMessage(ctx, parent, started.childId, message('second follow-up'))) - .toThrow(/not delivered/) + await expect(sendMessage(ctx, parent, started.childId, message('second follow-up'))) + .rejects.toThrow(/not delivered/) releaseLoad() const snapshot = await waitTerminal(ctx, first.taskId, parent) expect(snapshot.status).toBe('completed') diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 2b5ea80435..7b0ce56b0d 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: eb5d973566f01c05b43f4f56eff746b7af93f60b -README.zh.md: 5be640f9b6da6402ece0e1d15997d9e2970a7d1c +README.md: 6225b84f1274b61cae1d4ca567155dcc6e6a0888 +README.zh.md: 5c3ab3baa3ab86f33fe34026ddbdf97449cb4f92 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index eb5d973566..6225b84f12 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation and cold resume, optional child customization, result reading, cancellation, strict steering, and disposal—has one implementation here. +This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation and cold resume, optional child customization, result reading, cancellation, confirmed steering, and disposal—has one implementation here. ## Start contract @@ -31,7 +31,7 @@ The required request signal covers both startup and the live run. Before publica After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed. -Runs expose the strict `steer` capability: the synchronous checks and the `Agent.trySteer()` call share one frame, so delivery joins the observed step or throws. Delivery requires `AgentStatus.running`, an open turn and step in the child log, no committed structured capture, and acceptance before that step's final drain begins. Admission, between-step processing such as `agent/turn-stopping`, and a closed turn's durability flush all reject delivery. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. +Runs expose confirmed `steer`: a synchronous status check prevents the Agent-level idle fallback from starting an untracked turn, then the run submits through `Agent.steer()` and awaits that exact message's receipt. Fulfillment means a committed child request snapshot admitted the message; terminal turn policy, cancellation, disposal, or a settlement race rejects instead. A synchronously visible structured capture is rejected before submission because its terminal outcome is already authoritative. The run never falls through from rejected live delivery to a later queued turn or cold resume. ## Spawn and fork inputs diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 5be640f9b6..5c3ab3baa3 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、严格 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。 +本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、确认式 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。 ## 启动契约 @@ -31,7 +31,7 @@ 兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。 -运行公开严格的 `steer` 功能:同步检查与 `Agent.trySteer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的步骤,要么抛错。交付要求 `AgentStatus.running`、子 agent 日志中有开放的轮次和步骤、没有已提交的结构化捕获,并且在该步骤的最终 drain 开始前获接纳。提示词接纳、`agent/turn-stopping` 等步骤间处理,以及已关闭轮次的持久性 flush 都会拒绝交付。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback;否则会在运行结果读取后启动一个未被跟踪的轮次。 +运行公开确认式 `steer`:同步状态检查会阻止 Agent 层的空闲 fallback 启动未跟踪轮次,随后运行通过 `Agent.steer()` 提交消息,并等待该准确消息的回执。兑现表示某个已提交的子 agent 请求 snapshot 接纳了消息;结束轮次的策略、取消、dispose(资源释放)或结算竞态会改为拒绝。已同步可见的结构化捕获会在提交前被拒绝,因为其终态结果已经具有权威性。实时投递被拒绝后,运行绝不会转而进入之后的排队轮次或冷恢复。 ## Spawn 与 fork 输入 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index f48257ad02..8eb81c05ae 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -238,8 +238,8 @@ export async function resumeInProcessRun(request: SubagentResumeRequest): Promis * Drive one activation turn on a published child and wrap it as a run. The * caller has already created or resumed the agent; this owns the * signal-handoff race, the live abort listener, result collection past - * `boundary`, the continuable-run durability confirmation, strict steering, - * and disposal. + * `boundary`, the continuable-run durability confirmation, confirmed + * steering, and disposal. */ function driveTurn( handle: AgentHandle, @@ -299,46 +299,21 @@ function driveTurn( flags.cancelled = true return handle.dispose() }, - steer(content: ContentBlock[], steeringSource: MessageSource): void { - // Strict live delivery: the synchronous checks and Agent.trySteer() share - // one frame, so delivery joins the observed step or throws. The ordinary - // Agent.steer() idle fallback would instead queue the message and - // start a new, untracked turn after this run's result was read. + async steer(content: ContentBlock[], steeringSource: MessageSource): Promise { + // The status check and submission share one synchronous frame. An idle + // Agent.steer() would queue an untracked turn after this run's result. if (child.status !== 'running') { throw new Error(`subagent child "${childId}" is not running; the message was not delivered`) } - // Status stays `running` through the closed turn's durability flush, when - // ordinary steering would queue a later turn. Requiring an open turn - // keeps this activation's acknowledged delivery honest. - const lastBoundary = child.session.events.findLast( - event => event.type === 'turn/start' || event.type === 'turn/end', - ) - if (lastBoundary?.type !== 'turn/start') { - throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`) - } - // Between steps there is no current step whose final drain can own strict - // delivery. A message accepted during an open step is recorded at that - // step's settlement checkpoint before the continuation decision - // (cancellation remains the documented shared-outcome race). - const lastStep = child.session.events.findLast( - event => event.type === 'step/start' || event.type === 'step/end', - ) - if (lastStep?.type !== 'step/start') { - throw new Error(`subagent child "${childId}" is between steps; the message was not delivered`) - } - // A committed structured capture makes the pending step conclusion - // terminal. The capture is synchronously observable, so reject rather - // than acknowledge a message the run is about to drop. + // Avoid waiting for the structured terminal checkpoint when its outcome + // is already authoritative and synchronously visible. if (structured?.captured() !== undefined) { throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`) } - // The atomic Agent operation closes before the final drain, so this - // cannot acknowledge content that the current step will not record. - if (child.trySteer === undefined) { - throw new Error(`subagent child "${childId}" agent does not support strict steering; the message was not delivered`) - } - if (!child.trySteer(createUserMessage({ content, source: steeringSource }))) { - throw new Error(`subagent child "${childId}" passed its steering checkpoint; the message was not delivered`) + const receipt = child.steer(createUserMessage({ content, source: steeringSource })) + const outcome = await receipt.outcome + if (outcome.status === 'rejected') { + throw new Error(`subagent child "${childId}" stopped before steering admission; the message was not delivered`) } }, } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index a9ef98a892..d3396cd21a 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -120,26 +120,24 @@ describe('in-process structured output', () => { await run.dispose() }) - it('strict steer rejects delivery once the structured result is captured', async () => { + it('confirmed steering rejects delivery once the structured result is captured', async () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), ]) + // oxlint-disable-next-line prefer-const -- single assignment follows listener registration so pre-fulfillment events remain guardable. let run: Awaited> | undefined - let rejected: unknown + let delivery: Promise | undefined ctx.on('session/event', (session, event) => { if (session.header.parentSession === undefined || run === undefined - || event.type !== 'tool/result' || rejected !== undefined) return - try { - run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' }) - } catch (error: unknown) { - rejected = error - } + || event.type !== 'tool/result' || delivery !== undefined) return + delivery = run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' }) + void delivery?.catch(() => undefined) }) run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result - expect(rejected).toBeInstanceOf(Error) - expect((rejected as Error).message) - .toMatch(/already reported its structured result; the message was not delivered/) + if (delivery === undefined) throw new Error('structured result did not submit steering') + await expect(delivery) + .rejects.toThrow(/already reported its structured result; the message was not delivered/) expect(result.structured).toEqual({ answer: 7 }) await run.dispose() }) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index b26ca6b84d..18ca3aeb7a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -10,7 +10,8 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError } from '@deepseek-ai/dsh-subagent' -import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { resumeInProcessRun, startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -290,7 +291,7 @@ describe('startInProcessRun', () => { reserveTurnAdmission: () => undefined, updateInbox: () => 'not-found', followup(): void {}, - steer(): void {}, + steer() { return { outcome: Promise.resolve({ status: 'rejected' as const }) } }, inject(): void {}, cancel(): void {}, whenIdle: () => Promise.resolve(), @@ -381,156 +382,103 @@ describe('startInProcessRun', () => { expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) - it('strict steer rejects a settled child instead of queueing an untracked turn', async () => { + it('confirmed steering rejects a settled child instead of queueing an untracked turn', async () => { const { ctx, parent } = await setup([textResponse('done')]) const run = await startInProcessRun(request(parent), {}) await run.result - // The child is idle after its turn: Agent.steer() would silently QUEUE. - expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) }) - .toThrow(/not running; the message was not delivered/) + await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' })) + .rejects.toThrow(/not running; the message was not delivered/) const child = ctx.agents.get(run.id)! expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) await run.dispose() }) - it('strict steer rejects the between-steps turn-stopping window', async () => { - // Hold `agent/turn-stopping` open after the step closed and pending - // steering was folded into the continuation decision. - const { ctx, parent } = await setup([textResponse('quick')]) - let releaseStop: (() => void) | undefined - ctx.on('agent/turn-stopping', (agent) => { - if (agent.session.header.parentSession === undefined || releaseStop !== undefined) return undefined - return new Promise((resolve) => { - releaseStop = () => { resolve(undefined) } - }) - }) - const run = await startInProcessRun(request(parent), {}) - const child = ctx.agents.get(run.id)! - await new Promise((resolve) => { - const timer = setInterval(() => { - if (releaseStop !== undefined) { clearInterval(timer); resolve() } - }, 5) - }) - expect(child.status).toBe('running') - expect(() => { - run.steer!([{ type: 'text', text: 'too late for this turn' }], { kind: 'user' }) - }) - .toThrow(/between steps; the message was not delivered/) - releaseStop!() - await run.result - expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) - await run.dispose() - }) - - it('strict steer rejects reentrant delivery after the final drain begins', async () => { - const { ctx, parent } = await setup([textResponse('quick')]) - let run: Awaited> | undefined - let seeded = false - let rejected: unknown - ctx.on('session/event', (session, event) => { - if (session.header.parentSession === undefined || run === undefined) return - if (event.type === 'assistant/chunk' && !seeded) { - seeded = true - run.steer?.([{ type: 'text', text: 'accepted before the drain' }], { kind: 'user' }) - } else if (event.type === 'steering/message' && rejected === undefined) { - try { - run.steer?.([{ type: 'text', text: 'after the drain began' }], { kind: 'user' }) - } catch (error: unknown) { - rejected = error - } - } - }) - - run = await startInProcessRun(request(parent), {}) - const child = ctx.agents.get(run.id)! - await run.result - expect(seeded).toBe(true) - expect(rejected).toBeInstanceOf(Error) - expect((rejected as Error).message) - .toMatch(/passed its steering checkpoint; the message was not delivered/) - expect(child.session.events.filter(event => event.type === 'steering/message')).toHaveLength(1) - await run.dispose() - }) - - it('strict steer rejects an Agent implementation without atomic steering', async () => { - const childId = SessionId('custom-loop-child') - const childSession = new Session(childId) - childSession.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - childSession.append('step/start', { turn: 1, step: 1 }) - const idle = Promise.withResolvers() - const child = { - id: childId, - options: {}, - session: childSession, - status: 'running', - acceptsNextStep: false, - ctx: new Context(), - send(): void {}, - reserveTurnAdmission: () => undefined, - updateInbox: () => 'not-found', - followup(): void {}, - steer(): void {}, - inject(): void {}, - cancel(): void {}, - whenIdle: () => idle.promise, - } as Agent - const parentId = SessionId('custom-loop-parent') - const parent = { - id: parentId, - options: {}, - session: new Session(parentId), - ctx: { - get: () => undefined, - agents: { - create: () => Promise.resolve({ - agent: child, - dispose: () => { - idle.resolve(undefined) - return Promise.resolve() - }, - }), - }, + it('confirmed steering rejects when a concluding tool prevents request admission', async () => { + const { ctx, parent } = await setup([toolCallResponse('c1', 'finalize', {})]) + const enteredTool = Promise.withResolvers() + const releaseTool = Promise.withResolvers() + ctx.tools.register(defineContentToolFixture({ + name: 'finalize', + description: 'Finish the child run.', + parameters: {}, + async execute(_args, exec) { + enteredTool.resolve(undefined) + await releaseTool.promise + exec.concludeTurn() + return [{ type: 'text', text: 'final' }] }, - } as unknown as Agent - - const run = await startInProcessRun(request(parent), {}) - expect(() => { - run.steer!([{ type: 'text', text: 'unsupported strict delivery' }], { kind: 'user' }) - }) - .toThrow(/does not support strict steering; the message was not delivered/) - await run.dispose() - await run.result - }) - - it('strict steer rejects the closed-turn flush window where the loop discards steering', async () => { - // Hold the turn-end durability flush open: the turn has closed in the log - // and status is still `running`, exactly the window where the loop would - // discard a drained steering message instead of recording it. - const { ctx, parent } = await setup([textResponse('quick')]) - let releaseFlush: (() => void) | undefined - ctx.on('session/flush', (session) => { - if (session.header.parentSession === undefined || releaseFlush !== undefined) return - const lastEnd = session.events.findLast(event => event.type === 'turn/end') - if (lastEnd === undefined) return - return new Promise((resolve) => { releaseFlush = resolve }) - }) + })) const run = await startInProcessRun(request(parent), {}) const child = ctx.agents.get(run.id)! - // Wait until the child's turn has closed while the flush keeps it running. - await new Promise((resolve) => { - const timer = setInterval(() => { - if (releaseFlush !== undefined) { clearInterval(timer); resolve() } - }, 5) - }) - expect(child.status).toBe('running') - expect(() => { run.steer!([{ type: 'text', text: 'into the void' }], { kind: 'user' }) }) - .toThrow(/turn has already closed; the message was not delivered/) - releaseFlush!() + await enteredTool.promise + + const delivery = run.steer!([{ type: 'text', text: 'terminal race' }], { kind: 'user' }) + releaseTool.resolve(undefined) + await expect(delivery).rejects.toThrow(/stopped before steering admission; the message was not delivered/) await run.result expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) await run.dispose() }) + + it('confirmed steering fulfills only after the next request snapshot admits it', async () => { + const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')]) + const enteredStopping = Promise.withResolvers() + const releaseStopping = Promise.withResolvers() + let held = false + ctx.on('agent/turn-stopping', (agent) => { + if (agent.session.header.parentSession === undefined || held) return + held = true + enteredStopping.resolve(undefined) + return releaseStopping.promise + }) + + const run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + await enteredStopping.promise + + let settled = false + const delivery = run.steer!([{ type: 'text', text: 'after the first step' }], { kind: 'user' }) + .then(() => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) + releaseStopping.resolve(undefined) + await delivery + + const result = await run.result + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('after the first step') + expect((result.output[0] as { text?: string }).text).toBe('second') + const steering = child.session.events.find(event => event.type === 'steering/message') + expect(steering?.type === 'steering/message' && steering.data.message.source).toEqual({ kind: 'user' }) + await run.dispose() + }) + + it('carries steering from a non-terminal flush window into a tracked next turn', async () => { + const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')]) + const enteredFlush = Promise.withResolvers() + const releaseFlush = Promise.withResolvers() + let held = false + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined || held) return + if (!session.events.some(event => event.type === 'turn/end')) return + held = true + enteredFlush.resolve(undefined) + return releaseFlush.promise + }) + + const run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + await enteredFlush.promise + expect(child.status).toBe('running') + + const delivery = run.steer!([{ type: 'text', text: 'next tracked turn' }], { kind: 'user' }) + releaseFlush.resolve(undefined) + await delivery + const result = await run.result + expect(adapter.requests).toHaveLength(2) + expect(child.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) + expect((result.output[0] as { text?: string }).text).toBe('second') + await run.dispose() + }) }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 8b55dd25f9..aeb6ef8cb7 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -235,7 +235,7 @@ describe('dsh-subagent-spawn', () => { expect(result.stopReason).toBe('aborted') }) - it('exposes strict steer (no run-level resume): a settled child throws instead of queueing', async () => { + it('exposes confirmed steer (no run-level resume): a settled child rejects instead of queueing', async () => { const { ctx, parent } = await setup([textResponse('x')]) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) // A run represents one disposable activation: cold resume is a provider @@ -243,11 +243,11 @@ describe('dsh-subagent-spawn', () => { expect('resume' in run).toBe(false) expect(typeof run.steer).toBe('function') await run.result - // Strict live-only contract: after the child settles, delivery fails loud + // Confirmed live-only contract: after the child settles, delivery fails loud // rather than falling back to Agent.steer()'s idle queue (which would // start an untracked turn). - expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) }) - .toThrow(/not running; the message was not delivered/) + await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' })) + .rejects.toThrow(/not running; the message was not delivered/) await run.dispose() }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 68003363cd..d08d18e6e3 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -45,7 +45,7 @@ Start-time features are advertised in `provider.capabilities` because the servic - `toolFilter` — apply the requested child tool restriction. - `persona` — apply a per-child persona. -Runtime features are optional methods whose presence is the capability check: `SubagentRun.steer?` delivers strictly to the actively running child turn (it throws rather than queueing when the child is not running), and `SubagentProvider.resume?` reconstructs a persisted continuable child. A run represents one disposable activation, so it deliberately has no cold-resume operation — a disposed run cannot be reconstructed after restart. +Runtime features are optional methods whose presence is the capability check: `SubagentRun.steer?` fulfills only after a request snapshot in the active child admits the message and rejects rather than queueing an untracked turn, while `SubagentProvider.resume?` reconstructs a persisted continuable child. A run represents one disposable activation, so it deliberately has no cold-resume operation — a disposed run cannot be reconstructed after restart. ## The durable descriptor diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index eb26c79665..e202718f81 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -42,12 +42,12 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 +运行时功能通过可选方法是否存在来检查能力:`SubagentRun.steer?` 只有在活跃子 agent 的请求 snapshot 接纳消息后才会兑现,并会拒绝而非排队一个未跟踪轮次;`SubagentProvider.resume?` 则重建已持久化且可继续的子 agent。一次运行表示一个可 dispose(资源释放)的 activation,因此刻意不提供冷恢复操作;已释放的运行无法在重启后重建。 + ## 委派深度 该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth` 和 `delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。 -运行时功能是 `SubagentRun` 上的可选方法:`sendMessage?` 可对正在运行的子 agent 进行 steering(中途引导),`resume?` 则异步创建延续运行。方法是否存在就是能力检查。 - `inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。 ## 所有权与生命周期 diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 0806a01554..aa215be03c 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -28,7 +28,7 @@ export function SubagentRunId(id: string): SubagentRunId { * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent * degradation" rule). These static flags cover features needed before a run exists; runtime - * capabilities are optional methods whose presence is the capability — strict live steering + * capabilities are optional methods whose presence is the capability — confirmed live steering * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to * `maxDepth`; the other names match. @@ -221,19 +221,16 @@ export interface SubagentRun { */ dispose(): Promise /** - * OPTIONAL (strict live-steering capability): deliver additional content to - * the actively running child turn. STRICT means delivery joins the observed - * turn or fails — the implementation must synchronously verify, with no - * asynchronous boundary before delivery, that the child is running and its - * turn can still record the message, and must not fall back to a queue path - * that could start a new, untracked turn or silently drop the message after - * this run has settled. Throws when delivery cannot join the turn. A run - * represents one disposable activation, so it has no cold-resume operation; - * resuming a settled child goes through {@link SubagentProvider.resume}. - * `source` is retained on the child's logged steering message without - * changing its user role in model history. + * OPTIONAL (confirmed live-steering capability): submit additional content + * to the active child and fulfill only after a committed request snapshot + * admits it. Rejects when terminal policy, cancellation, disposal, or a lost + * settlement race prevents admission; it never falls through to a queued + * untracked turn or cold resume. A run represents one disposable activation, + * so resuming a settled child goes through {@link SubagentProvider.resume}. + * `source` is retained on the admitted steering message without changing its + * user role in model history. */ - steer?(content: ContentBlock[], source: MessageSource): void + steer?(content: ContentBlock[], source: MessageSource): Promise } /** diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 927162d21d..93c401007e 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -101,7 +101,7 @@ describe('dsh-tool-subagent-control', () => { // Reach past the tool into the control service to fake a running route // deterministically: the tool is a thin adapter, so its steered wording is // what this test pins. - ctx.subagentControl.sendMessage = (agent, _childId, message, messageSource) => { + ctx.subagentControl.sendMessage = async (agent, _childId, message, messageSource) => { steered = (message[0] as { text: string }).text source = messageSource return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 765bdebf86..fb8ac03c23 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -26,7 +26,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { acceptsNextStep: false, ctx: scopeFiber.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: (): 'not-found' => 'not-found', diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 709fb4f181..a62b695b72 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -228,7 +228,7 @@ export async function createTuiTestHarness { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() }) @@ -5553,7 +5553,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -5588,14 +5588,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -5626,7 +5626,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -5670,7 +5670,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 61d7894711..5c88ab3fc2 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { "AGENTS.md": 1775, "docs/AGENTS.md": 1150, - "docs/architecture.md": 2040, + "docs/architecture.md": 2160, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1100, From 43151ed9c015058ad3484520733766ab7f048000 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 14:57:54 +0800 Subject: [PATCH 036/114] fix(subagent): preserve cancellation during durability --- ...continuable-background-subagents.i18n.yaml | 4 +-- ...-07-21-continuable-background-subagents.md | 4 +-- ...-21-continuable-background-subagents.zh.md | 4 +-- .../tests/subagent-control.spec.ts | 23 ++++++++++++++ .../subagent-inprocess/README.i18n.yaml | 4 +-- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 22 ++++++++----- .../tests/subagent-inprocess.spec.ts | 31 +++++++++++++++++++ 9 files changed, 78 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 5c1d407e1d..c3aacff7e2 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-continuable-background-subagents.md: b5683f7e4a81a65b176ff4b4306c1ad0b761cc58 -2026-07-21-continuable-background-subagents.zh.md: 0b0f22d0945bf270267df1698b9145f0ab4b04f1 +2026-07-21-continuable-background-subagents.md: 67388825d93bd4f6f39a11f6deec0aeb42c2ed2a +2026-07-21-continuable-background-subagents.zh.md: 8a8fe6f0abf7998c4d34a21ffb8db57651968fdc diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index b5683f7e4a..67388825d9 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -77,7 +77,7 @@ Cold resume cannot depend on an optional method of the old `SubagentRun`, becaus `SubagentControlService`'s resume path loads the known child session, folds its descriptor, authorizes the persisted `parentSession`, and runs inside the Task it creates. It passes a fully resolved request, including the Task-owned cancellation signal, to the low-level `SubagentService.resume(provider, request)`, whose only responsibility is capability-checked provider dispatch and the ordinary run lifecycle observation used by `start`. The selected `SubagentProvider.resume?()` owns transport-specific reconstruction (in-process: `parent.ctx.agents.resume` under the currently loaded parent scope) and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag exists. `SubagentControlService.sendMessage()` chooses between the associated run's `steer?()` operation and this cold-resume path. Neither the low-level service nor a provider enumerates durable children or associates Tasks. -The background tool validates and snapshots descriptor inputs before calling `TaskService.start()`. A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. In-process continuable providers perform a final session flush after the child becomes idle and before reading the result; this retries a failed loop checkpoint while the child is still live. If the final confirmation fails, the provider rejects instead of returning unconfirmed output, the control service disposes the run, and the already-created Task settles as `failed` with the durability diagnosis in its detail. Foreground one-shot runs retain the loop's best-effort checkpoint behavior. In-process spawn and fork reconstruct composition under the currently loaded parent scope. A fork resume loads the child's own persisted transcript, which already contains the completed-turn prefix captured at initial creation; it never forks the parent's newer history again. Resuming a parent does not eagerly resume its children. +The background tool validates and snapshots descriptor inputs before calling `TaskService.start()`. A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. In-process continuable providers perform a final session flush after the child becomes idle and before reading the result; this retries a failed loop checkpoint while the child is still live. If the final confirmation fails, the provider rejects instead of returning unconfirmed output, the control service disposes the run, and the already-created Task settles as `failed` with the durability diagnosis in its detail. Cancellation during the confirmation owns the still-unpublished activation result, so a completed child turn or a later checkpoint failure cannot replace the Task's `killed` outcome. Foreground one-shot runs retain the loop's best-effort checkpoint behavior. In-process spawn and fork reconstruct composition under the currently loaded parent scope. A fork resume loads the child's own persisted transcript, which already contains the completed-turn prefix captured at initial creation; it never forks the parent's newer history again. Resuming a parent does not eagerly resume its children. TODO (ACP continuation): persist the remote ACP session id as provider-specific descriptor data and implement `AcpProvider.resume?()` as spawn, initialize, `loadSession`, then prompt. The initial ACP run must verify `initialize.agentCapabilities.loadSession`, and every resumed process must use the same durable backend; replayed history from `loadSession` must not be collected as the new activation's output. Because ACP load support is negotiated per child rather than established solely by the provider method's presence, this follow-up must also define how a start result advertises child-specific continuation before ACP children enter the durable catalog. @@ -107,7 +107,7 @@ Task records and active-run associations are process-local. Persistence makes th ## Testing -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id; `task_kill` during a run or during cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task and retains the caller source; cold follow-ups accumulate turns in one durable transcript with their source and declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once. +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id; `task_kill` during a run, a final durability checkpoint, or cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task and retains the caller source; cold follow-ups accumulate turns in one durable transcript with their source and declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once. - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_message`, a one-shot provider keeps the plain task acknowledgement, and a resumable provider without the control service fails loud. - The keyless ACP snapshot scenario `subagent-continuable` (examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, a final durability-confirmation failure rendered through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 0b0f22d094..8a8fe6f0ab 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -77,7 +77,7 @@ durable child Session `SubagentControlService` 的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。 -后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终会话 flush;此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,控制服务会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 +后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终会话 flush;此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,控制服务会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。最终确认期间发生取消时,尚未发布的激活结果由取消操作接管;即使 child 轮次已记录为完成,或之后的检查点失败,也不能取代 Task 的 `killed` 结果。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 TODO(ACP 继续执行):将远端 ACP session id 作为提供方专用描述符数据持久化,并实现 `AcpProvider.resume?()`,依次执行 spawn、initialize、`loadSession` 和 prompt。初始 ACP run 必须检查 `initialize.agentCapabilities.loadSession`,恢复后的每个进程必须使用同一个持久化后端;`loadSession` 回放的历史消息不得计入新激活的输出。由于 ACP 的加载支持是按 child 协商的,不能仅根据提供方是否存在该方法来确定,因此该后续工作还必须定义 start 结果如何声明单个 child 支持继续执行,之后才能将 ACP child 写入持久化目录。 @@ -107,7 +107,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 测试 -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,不创建第二个 Task,并保留调用方来源;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建其来源和声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间、最终持久性检查点执行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,不创建第二个 Task,并保留调用方来源;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建其来源和声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 - 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index a1c12228d7..9e9e4573e8 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -237,6 +237,29 @@ describe('SubagentControlService.startContinuable', () => { expect(snapshot.status).toBe('killed') expect(ctx.agents.get(started.childId)).toBeUndefined() }) + + it('task_kill during the final durability checkpoint settles killed', async () => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + const checkpointStarted = Promise.withResolvers() + const releaseCheckpoint = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session.header.parentSession === undefined) return + flushes++ + if (flushes !== 2) return + checkpointStarted.resolve(undefined) + await releaseCheckpoint.promise + }) + const started = ctx.subagentControl.startContinuable(startSpec(parent)) + + await checkpointStarted.promise + expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested') + releaseCheckpoint.resolve(undefined) + + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('killed') + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) }) describe('SubagentControlService.sendMessage', () => { diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 7b0ce56b0d..3cf7ffb480 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: 6225b84f1274b61cae1d4ca567155dcc6e6a0888 -README.zh.md: 5c3ab3baa3ab86f33fe34026ddbdf97449cb4f92 +README.md: 1bbbfd282fe98f73b1828b22a95efd34e5ddc0ab +README.zh.md: d6dc91415beb3986ad226a8467ce2abbabce8591 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 6225b84f12..1bbbfd282f 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,7 +14,7 @@ The driver follows this sequence: 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result. This final confirmation retries events retained after a failed turn checkpoint; if it still fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the backend failure as `cause`, and names the resumability risk in its message. Foreground runs keep the loop's best-effort checkpoint behavior. +5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result. This final confirmation retries events retained after a failed turn checkpoint; if it still fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the backend failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior. 6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records. The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 5c3ab3baa3..d6dc91415b 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 对于可继续的启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`。这次最终确认会重试轮次检查点失败后保留的事件;若仍然失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,保留后端失败作为 `cause`,并在消息中指出可恢复性风险。前台运行仍采用循环的尽力而为检查点行为。 +5. 对于可继续的启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`。这次最终确认会重试轮次检查点失败后保留的事件;若仍然失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,保留后端失败作为 `cause`,并在消息中指出可恢复性风险。在这次等待期间取消 activation 时,即使已记录完成的轮次,或检查点随后失败,取消仍决定尚未发布的结果。前台运行仍采用循环的尽力而为检查点行为。 6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 8eb81c05ae..20c258e195 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -272,11 +272,13 @@ function driveTurn( try { await child.ctx.sessions.flush(child.session) } catch (error: unknown) { - throw new SubagentError( - `subagent "${childId}" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`, - 'DURABILITY_FAILED', - { cause: error }, - ) + if (!signal.aborted) { + throw new SubagentError( + `subagent "${childId}" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`, + 'DURABILITY_FAILED', + { cause: error }, + ) + } } } return readResult( @@ -284,6 +286,7 @@ function driveTurn( boundary, flags.cancelled, structured ? { captured: structured.captured() } : undefined, + durability === 'required' && signal.aborted, ) } finally { signal.removeEventListener('abort', onAbort) @@ -325,6 +328,7 @@ function readResult( boundary: number, cancelled: boolean, structured?: { captured?: { value: unknown } | undefined }, + cancellationOwnsCompleted = false, ): SubagentResult { const own = child.session.events.slice(boundary) const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message') @@ -332,9 +336,11 @@ function readResult( const output: ContentBlock[] = lastMessage?.data.message.content ?? [] const recorded = toStopReason(lastEnd?.data.reason) // Disposal can tear the owner down before the loop records its ordinary - // `aborted` end, yielding `disposed` instead. A requested cancellation owns - // every non-completed in-flight outcome; a turn already completed stays so. - const stopReason: SubagentStopReason = cancelled && recorded !== 'completed' + // `aborted` end, yielding `disposed` instead. Activation cancellation during + // its final durability checkpoint also owns a recorded completed turn because + // the provider has not published that result yet. + const stopReason: SubagentStopReason = cancelled + && (recorded !== 'completed' || cancellationOwnsCompleted) ? 'aborted' : recorded if (structured !== undefined) { diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 18ca3aeb7a..50f8b03370 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -111,6 +111,37 @@ describe('startInProcessRun', () => { await run.dispose() }) + it.each([ + { checkpoint: 'succeeds', failure: undefined }, + { checkpoint: 'fails', failure: new Error('disk full') }, + ])('lets cancellation own the result when the final durability checkpoint $checkpoint', async ({ failure }) => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + const checkpointStarted = Promise.withResolvers() + const releaseCheckpoint = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session.header.parentSession === undefined) return + flushes++ + if (flushes !== 2) return + checkpointStarted.resolve(undefined) + await releaseCheckpoint.promise + if (failure !== undefined) throw failure + }) + const controller = new AbortController() + + const run = await startInProcessRun({ + ...continuableRequest(parent), + signal: controller.signal, + }, {}) + await checkpointStarted.promise + controller.abort() + releaseCheckpoint.resolve(undefined) + + await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) + expect(flushes).toBe(2) + await run.dispose() + }) + it('keeps foreground runs best-effort when their turn checkpoint fails', async () => { const { ctx, parent } = await setup([textResponse('driver answer')]) let flushes = 0 From 0a95ad8cc03c1147990206b19e99e9a69b49077e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 16:33:44 +0800 Subject: [PATCH 037/114] fix(sdk): mount task controls for subagents --- ...26-07-21-continuable-background-subagents.i18n.yaml | 6 +++--- .../2026-07-21-continuable-background-subagents.md | 3 ++- .../2026-07-21-continuable-background-subagents.zh.md | 3 ++- packages/sdk/helper/src/features/builtin/index.ts | 2 ++ packages/sdk/helper/tests/project.spec.ts | 10 ++++++++++ 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index c3aacff7e2..956acaae45 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-21-continuable-background-subagents.md: 67388825d93bd4f6f39a11f6deec0aeb42c2ed2a -2026-07-21-continuable-background-subagents.zh.md: 8a8fe6f0abf7998c4d34a21ffb8db57651968fdc +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +2026-07-21-continuable-background-subagents.md: 2ee8a7ce19bba3f44a5bd58429e323e0eb818d36 +2026-07-21-continuable-background-subagents.zh.md: 53a4797dbafb7c69a45b99646be6bb58b465469b diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index 67388825d9..2ee8a7ce19 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -35,7 +35,7 @@ Every later turn creates another Task. Its producer resources cover only that ac Opening a child session in a human-facing adapter reads its persisted transcript and does not resume an Agent merely to display it. Human input starts or joins the same Task-backed activation used by parent input through the control service. A human-started Task retains the exact currently loaded parent Agent as its notification target, and `task_output` remains the single result path. The existing completion listener injects at most one unsolicited notice while the Task is unreported; `kill`, a terminal read, or a terminal wait may mark it reported and suppress that notice. Human interaction is therefore permitted only while that parent instance remains live. A user-owned conversation that may outlive the parent and explicitly merge a conclusion back belongs to [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md), not this Task-owned lifecycle. -`TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations. +`TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. SDK-generated spawn and fork compositions mount `@deepseek-ai/dsh-tasks` and `@deepseek-ai/dsh-tool-tasks` with the subagent control pair. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations. Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as `killed`; the messages do not have independent results or cancellation rights. Independent cancellation requires a later message to start a separate turn instead of steering the current one. @@ -110,6 +110,7 @@ Task records and active-run associations are process-local. Persistence makes th - `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id; `task_kill` during a run, a final durability checkpoint, or cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task and retains the caller source; cold follow-ups accumulate turns in one durable transcript with their source and declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once. - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_message`, a one-shot provider keeps the plain task acknowledgement, and a resumable provider without the control service fails loud. +- `packages/sdk/helper/tests/project.spec.ts` pins the Task service and model-facing Task controls in generated spawn and fork compositions. - The keyless ACP snapshot scenario `subagent-continuable` (examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, a final durability-confirmation failure rendered through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 8a8fe6f0ab..53a4797dba 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -35,7 +35,7 @@ durable child Session 用户界面适配器打开 child 会话时,只读取持久化 transcript,不会仅为展示而恢复 agent。用户输入通过控制服务,启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。因此,仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。 -如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 +如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 控制插件对的同时,也会挂载 `@deepseek-ai/dsh-tasks` 与 `@deepseek-ai/dsh-tool-tasks`。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 @@ -110,6 +110,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 - `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间、最终持久性检查点执行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,不创建第二个 Task,并保留调用方来源;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建其来源和声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 +- `packages/sdk/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。 - 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 ## 影响 diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 31b4fc77c3..e72bb72c8a 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -212,6 +212,8 @@ config: // The control pair rides every resumable in-process option: background // delegation on spawn/fork is continuable and advertises send_message. baseResources: [ + { kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks' }, + { kind: 'npm-cordis-config-entry', id: 'tool-tasks', package: '@deepseek-ai/dsh-tool-tasks' }, { kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }, { kind: 'npm-cordis-config-entry', id: 'subagent-control', package: '@deepseek-ai/dsh-subagent-control' }, { kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' }, diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 648ec11428..d006465e77 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -204,6 +204,16 @@ describe('SdkProject and ProjectEditSession', () => { expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models') }) + it.each(['spawn', 'fork'] as const)('mounts Task controls for %s subagents', async (option) => { + const project = await createCommitted([selection('subagent', [option])]) + expect(project.cordis.entry('tasks')?.name).toBe('@deepseek-ai/dsh-tasks') + expect(project.cordis.entry('tool-tasks')?.name).toBe('@deepseek-ai/dsh-tool-tasks') + expect(project.packageManifest().dependencies).toMatchObject({ + '@deepseek-ai/dsh-tasks': '^0.0.1', + '@deepseek-ai/dsh-tool-tasks': '^0.0.1', + }) + }) + it('round-trips embed app projects without a front-door Cordis config entry', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-')) temporary.push(root) From 88f913a9aeb3e84490e995510fe29696995166ed Mon Sep 17 00:00:00 2001 From: Dudu <3414513905@qq.com> Date: Mon, 27 Jul 2026 00:00:14 +0800 Subject: [PATCH 038/114] refactor(subagent): merge continuation control service --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 10 +- ...-21-continuable-background-subagents.zh.md | 10 +- ...6-merge-subagent-control-service.i18n.yaml | 6 + ...26-07-26-merge-subagent-control-service.md | 37 ++++++ ...07-26-merge-subagent-control-service.zh.md | 37 ++++++ apps/cli/composition.md | 3 - apps/cli/config/base.cordis.yml | 10 +- apps/cli/package.json | 1 - docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 3 +- docs/architecture.zh.md | 3 +- docs/capability-seams.md | 12 +- docs/config-catalog.md | 11 +- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 60 +++------ docs/core-data-structures/subagent.md | 14 +- docs/event-producer-consumer.md | 8 +- docs/module-graph.md | 40 +++--- docs/tool-catalog.md | 6 +- examples/acp-agent/composition.md | 3 - examples/acp-agent/cordis.yml | 10 +- .../system-prompt.expected.md | 8 +- .../tool-schemas.expected.json | 8 +- .../both-mode-turn/tool-schemas.expected.json | 8 +- .../code-mode-turn/system-prompt.expected.md | 8 +- .../lsp-definition/tool-schemas.expected.json | 8 +- .../pty-tools/tool-schemas.expected.json | 8 +- .../tool-schemas.expected.json | 8 +- .../text-turn/tool-schemas.expected.json | 8 +- .../web-fetch/tool-schemas.expected.json | 8 +- examples/headless-agent/composition.md | 3 - examples/headless-agent/cordis.yml | 10 +- examples/package.json | 1 - .../cordis/tool-cordis/src/api-catalog.ts | 18 +-- .../sdk/helper/src/features/builtin/index.ts | 13 +- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 9 +- packages/subagent/README.zh.md | 9 +- packages/subagent/subagent-control/README.md | 37 ------ .../subagent/subagent-control/package.json | 55 -------- .../subagent-control/src/invariant.ts | 32 ----- .../subagent/subagent-control/tsconfig.json | 39 ------ packages/subagent/subagent/README.md | 13 +- packages/subagent/subagent/package.json | 12 ++ .../index.ts => subagent/src/continuation.ts} | 54 ++++---- packages/subagent/subagent/src/descriptor.ts | 2 +- packages/subagent/subagent/src/index.ts | 83 ++++++++++-- packages/subagent/subagent/src/types.ts | 8 +- .../tests/continuation.spec.ts} | 103 +++++++-------- .../subagent/subagent/tests/service.spec.ts | 11 ++ packages/subagent/subagent/tsconfig.json | 6 + .../subagent/tool-subagent-control/README.md | 4 +- .../tool-subagent-control/package.json | 5 +- .../tool-subagent-control/src/index.ts | 12 +- .../tool-subagent-control/src/invariant.ts | 2 +- .../tests/tool-subagent-control.spec.ts | 13 +- .../tool-subagent-control/tsconfig.json | 2 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 5 +- packages/subagent/tool-subagent/README.zh.md | 5 +- packages/subagent/tool-subagent/package.json | 2 - packages/subagent/tool-subagent/src/index.ts | 53 ++++---- .../tool-subagent/tests/tool-subagent.spec.ts | 120 +++++++++--------- packages/subagent/tool-subagent/tsconfig.json | 3 - pnpm-lock.yaml | 66 +--------- python/sdk-runtime/package.json | 1 - scripts/gen-doc-graphs.ts | 14 +- scripts/gen-tool-catalog.ts | 8 +- scripts/type-equiv.manifest.json | 2 +- tsconfig.host.json | 1 - 71 files changed, 543 insertions(+), 673 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md create mode 100644 .agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md delete mode 100644 packages/subagent/subagent-control/README.md delete mode 100644 packages/subagent/subagent-control/package.json delete mode 100644 packages/subagent/subagent-control/src/invariant.ts delete mode 100644 packages/subagent/subagent-control/tsconfig.json rename packages/subagent/{subagent-control/src/index.ts => subagent/src/continuation.ts} (92%) rename packages/subagent/{subagent-control/tests/subagent-control.spec.ts => subagent/tests/continuation.spec.ts} (90%) diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 956acaae45..d9be549a27 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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-07-21-continuable-background-subagents.md -2026-07-21-continuable-background-subagents.md: 2ee8a7ce19bba3f44a5bd58429e323e0eb818d36 -2026-07-21-continuable-background-subagents.zh.md: 53a4797dbafb7c69a45b99646be6bb58b465469b +2026-07-21-continuable-background-subagents.md: 42e9f6dd653dee8df4b8c068e3a33b7c69f8dc4b +2026-07-21-continuable-background-subagents.zh.md: e643e81f40074c3cc0cd6398b3f1d24e0d9bc988 diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index 2ee8a7ce19..42e9f6dd65 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-07-21-continuable-background-subagents.zh.md) +The service-placement and provider-capability policy in this record are superseded by [Merge subagent control into the subagent service](../simplification/2026-07-26-merge-subagent-control-service.md). The continuation, persistence, Task, routing, authorization, and durability semantics remain in force; references below to the control service describe the internal continuation manager now exposed through `ctx.subagents`. + ## Problem The subagent tool treats each delegation as one owned `SubagentRun`: foreground calls and background Tasks collect the result and then dispose the run. Disposal bounds the number of live child Agents and releases their scoped services, listeners, and provider resources. The persisted child session may survive, but the parent has no durable catalog or tool path for discovering that child and starting another turn on it. @@ -101,15 +103,15 @@ Task records and active-run associations are process-local. Persistence makes th **Keep `resume?()` on the disposed run.** Retaining a disposed `SubagentRun` only to call `resume()` makes the old run double as a durable child handle and cannot reconstruct that object after restart. Service dispatch plus provider reconstruction makes the persistence boundary explicit. -**Put control orchestration on `SubagentService`.** This would let one service look up descriptors, associate Tasks, and dispatch providers, but would make the collection-agnostic provider seam depend on one consumer's persistence and Task policy. A separate control service keeps start/resume transport reusable by foreground and non-Task consumers while giving tools and UI one orchestration path. +**Put control orchestration on `SubagentService`.** This service-placement alternative was later adopted by the [merged-service decision](../simplification/2026-07-26-merge-subagent-control-service.md), which keeps raw start/resume transport reusable while isolating optional Task and persistence work in an injected internal manager. **Add explicit activation phases.** Public `starting`/`running`/`settling` states could describe admission and cleanup precisely, but would add a lifecycle protocol the implementation does not otherwise need. The synchronous association install closes duplicate process-local cold resume without exposing those phases. ## Testing -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id; `task_kill` during a run, a final durability checkpoint, or cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task and retains the caller source; cold follow-ups accumulate turns in one durable transcript with their source and declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once. +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the service-allocated child id; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above. - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. -- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_message`, a one-shot provider keeps the plain task acknowledgement, and a resumable provider without the control service fails loud. +- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the configured background route: continuable mode requires a resumable provider and returns both ids without requiring `send_message`, while one-shot mode keeps the plain Task acknowledgement even when the provider can resume. - `packages/sdk/helper/tests/project.spec.ts` pins the Task service and model-facing Task controls in generated spawn and fork compositions. - The keyless ACP snapshot scenario `subagent-continuable` (examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, a final durability-confirmation failure rendered through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. @@ -117,7 +119,7 @@ Task records and active-run associations are process-local. Persistence makes th - Every follow-up after settlement pays persistence load and scoped setup cost; in exchange, live children stay bounded by concurrent work rather than historical session count. Continuable creation fails clearly when persistence is unavailable or the stored composition cannot be reconstructed. - Two callers may still race a stopped child through paths outside the control service. The Agent registry prevents duplicate same-session publication; a losing Task fails and its message is not delivered. A message may also race cancellation, terminal status publication, or run disposal. Admission is not claimed to be atomic or exactly-once; the synchronous process-local association install closes duplicate cold resume through the control service without a public lifecycle state machine. -- Driving a continuable child through the ordinary Agent API bypasses its Task association. The control service rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through `SubagentControlService.sendMessage()`. +- Driving a continuable child through the ordinary Agent API bypasses its Task association. `ctx.subagents` rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through `SubagentService.sendMessage()`. - The active-run association coordinates only one runtime. Concurrent resume from multiple processes is not serialized; that deployment requires a persistence-level lease or compare-and-set operation. - Human interaction requires the exact parent Agent instance to remain live because owner disposal cancels and removes its Tasks. It also requires an attached Task control surface. Standalone child interaction requires a future separation between Task access ownership and durable notification targeting. - The background tool returns child and Task ids before child publication and descriptor durability. Startup failure, a failed final durability confirmation, or process exit before the first child flush leaves the Task failed and may leave an unmaterialized or stale child id; by-id control reports missing state as unavailable rather than retroactively changing the tool acknowledgement. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 53a4797dba..e643e81f40 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-07-21-continuable-background-subagents.md) | 中文 +本记录中的服务放置与提供方功能策略已由[将 subagent 控制合并到 subagent 服务](../simplification/2026-07-26-merge-subagent-control-service.md)取代。继续执行、持久化、Task、路由、授权和持久性语义仍然有效;下文所提控制服务,是指现已通过 `ctx.subagents` 公开的内部继续执行管理器。 + ## 问题 subagent 工具将每次委派视为一个独占的 `SubagentRun`:前台调用和后台 Task 收集结果后 dispose(资源释放)该 run。这种所有权关系能够限制存活 child agent(智能体)的数量,并释放其作用域服务、监听器及提供方资源。持久化的 child 会话可能继续存在,但 parent 缺少持久化目录和工具路径,无法发现该 child 并为其启动另一轮次。 @@ -101,15 +103,15 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 **在已 dispose 的 run 上保留 `resume?()`。** 如果仅为调用 `resume()` 而保留已 dispose 的 `SubagentRun`,旧 run 会同时充当持久化 child handle,而且进程重启后无法重建该对象。由服务分发、提供方重建,可明确表达持久化边界。 -**将控制编排放在 `SubagentService` 上。** 这样一个服务就能查找描述符、关联 Task 并分发提供方,但会迫使不感知集合的提供方 seam 依赖某个消费方的持久化与 Task 策略。单独的控制服务让前台及不使用 Task 的消费方可以复用 start/resume 传输,同时为工具和 UI 提供统一的编排路径。 +**将控制编排放在 `SubagentService` 上。** 后来的[服务合并决策](../simplification/2026-07-26-merge-subagent-control-service.md)采用了这一服务放置方案;该方案保持底层 start/resume 传输可复用,同时将可选的 Task 与持久化工作隔离在注入的内部管理器中。 **增加显式激活阶段。** 公开的 `starting`/`running`/`settling` 状态可以准确描述准入和清理,但会引入实现本身并不需要的生命周期协议。同步安装关联无需暴露这些阶段,即可消除进程内重复的 cold resume。 ## 测试 -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间、最终持久性检查点执行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,不创建第二个 Task,并保留调用方来源;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建其来源和声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在服务分配的 child id 下持久化;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述契约。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 -- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 +- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖配置的后台路由:可继续模式要求提供方可恢复,并在不要求 `send_message` 的情况下返回两个 id;即使提供方可以恢复,一次性模式仍保持普通的 Task 确认消息。 - `packages/sdk/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。 - 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 @@ -117,7 +119,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 - 每次完成结算后的后续轮次都需要承担持久化加载和作用域 setup 成本;作为交换,存活 child 的数量受并发工作量限制,而不是随历史会话数量增长。持久化不可用或存储的组合配置无法重建时,可继续 child 的创建会明确失败。 - 两个调用方仍可能通过控制服务外部的路径争抢已停止的 child。Agent 注册表会阻止相同会话的重复发布;失败的 Task 会失败,且其消息不会送达。消息也可能与取消、终态状态发布或 run dispose 发生竞态。准入不承诺原子或恰好执行一次;在进程内同步安装的关联无需公开生命周期状态机,即可通过控制服务消除重复的 cold resume。 -- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。控制服务会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentControlService.sendMessage()` 提交用户输入。 +- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。`ctx.subagents` 会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentService.sendMessage()` 提交用户输入。 - 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。 - 用户交互要求作为 owner 的那个精确 parent Agent 实例保持存活,因为 dispose owner 会取消并移除其 Task。用户交互还要求附加 Task 控制面。若要单独与 child 交互,后续必须将 Task 访问所有权与持久化通知目标分离。 - 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、最终持久性确认失败,或进程在 child 首次 flush 之前退出,都会使 Task 失败,并可能留下 unmaterialized 或陈旧的 child id;按 id 的控制操作会将缺失状态报告为不可用,而不会追溯修改工具确认消息。 diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml new file mode 100644 index 0000000000..1068f5f578 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.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/simplification/2026-07-26-merge-subagent-control-service.md +2026-07-26-merge-subagent-control-service.md: a5ce673997502ef6fbd7c66ff4e94e301d4192ba +2026-07-26-merge-subagent-control-service.zh.md: cf867ab444438b7ee62cde68a3d53e83c3d049d1 diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md new file mode 100644 index 0000000000..a5ce673997 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md @@ -0,0 +1,37 @@ +# Agent Note: Merge subagent control into the subagent service + +Status: implemented + +English | [中文](2026-07-26-merge-subagent-control-service.zh.md) + +## Problem + +Continuable-child orchestration originally lived in a separate `ctx.subagentControl` service above the raw `ctx.subagents` provider seam. That split kept provider dispatch independent of Tasks and persistence, and gave model and human adapters one orchestration contract. In practice the two services described one capability family, every continuable caller needed both, and the provider-bound delegation tool had to infer policy from `provider.resume` and inspect whether the control service and `send_message` tool happened to be loaded. This made sibling plugin presence decide execution semantics and coupled starting continuable work to an optional follow-up surface. + +## Decision + +`SubagentService` is the only public service. It retains raw `start(name, request)` and `resume(name, request)` for callers that own run collection, and exposes `startContinuable(spec)` and `sendMessage(...)` for durable Task-backed activations. The standalone `@deepseek-ai/dsh-subagent-control` package and `ctx.subagentControl` key are deleted; the optional `@deepseek-ai/dsh-tool-subagent-control` package injects `ctx.subagents` directly. + +The continuation implementation remains an internal manager rather than expanding the provider registry's core state. `SubagentService` creates it through `ctx.inject(['tasks', 'agents'], ...)`, so the injected Cordis child fiber owns its Task completion listener and teardown effects. Loading the provider registry does not require Tasks or persistence. The manager exists only while Tasks and Agents are available, and each continuation operation resolves session persistence at the point it needs durability. Disposing that fiber cancels and settles active continuations before releasing their associations. + +`startContinuable` remains distinct from raw `start` because it has a different ownership and timing contract: it allocates the durable child id, creates the Task, and returns both ids synchronously while startup continues inside the Task. Raw `start` instead awaits provider publication and transfers a holder-owned run. Folding the method onto `start` through flags or return unions would broaden the low-level contract and create more change than keeping the existing explicit entry. + +Each `@deepseek-ai/dsh-tool-subagent` instance selects `backgroundMode: 'one-shot' | 'continuable'`, defaulting to `one-shot`. This configuration is policy; `provider.resume` is only the capability check for configured continuable mode. A resumable provider can therefore still run one-shot background work. The `send_message` tool is an independent adapter: loading or omitting it neither enables nor disables `startContinuable`. + +## Alternatives considered + +**Keep the separate service.** This preserves the strongest dependency separation, but every production continuable path composes both services and the extra public key exposes an architectural distinction callers do not need. The internal manager preserves optional Task and persistence dependencies without a second service. + +**Infer continuable mode from `provider.resume`.** Method presence correctly states cold-resume capability but not deployment policy. It forced every resumable provider into continuable background semantics and made missing sibling plugins a runtime error. Explicit tool configuration separates choice from capability. + +**Register continuation access or inspect the follow-up tool.** A registry could tell the delegation tool whether a continuation surface exists, but starting durable work does not require any follow-up adapter. Such a registry would encode UI composition into execution policy and recreate the sibling dependency under another name. + +**Merge raw and continuable starts into one method.** A flag on `start` would return either a ready run or immediate Task and child identities, weakening a simple ownership boundary. Keeping `startContinuable` is the smaller change and preserves both contracts explicitly. + +## Consequences + +- The service topology has one public key and one package fewer while raw provider dispatch remains usable without Tasks or persistence. +- Continuable mode fails at provider mount when the configured provider lacks `resume`; missing Tasks, Agents, or persistence still fail at the earliest operation that requires them. +- Follow-up delivery remains optional. Deployments may start and collect continuable work through Task tools without exposing `send_message`. +- The continuation manager is still Task- and persistence-aware inside the `dsh-subagent` package, so the package declares optional peer dependencies on those services even though ordinary `start` and `resume` callers do not need them. +- Existing continuation races, authorization, durability, cancellation, and settle-then-dispose semantics are unchanged and remain pinned by the migrated `subagent` tests. diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md new file mode 100644 index 0000000000..cf867ab444 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 将 subagent 控制合并到 subagent 服务 + +Status: implemented + +[English](2026-07-26-merge-subagent-control-service.md) | 中文 + +## 问题 + +可继续 child 的编排最初位于原始 `ctx.subagents` 提供方 seam 之上的独立 `ctx.subagentControl` 服务中。该拆分使提供方分发与 Task 和持久化无关,并为模型与人工适配器提供统一的编排契约。实践中,两个服务属于同一组功能,每个可继续调用方都需要二者,而绑定提供方的委派工具必须根据 `provider.resume` 推断策略,并检查控制服务与 `send_message` 工具是否碰巧已加载。如此一来,配套插件是否存在会决定执行语义,并将可继续工作的启动耦合到可选的后续操作接口。 + +## 决策 + +`SubagentService` 是唯一的公开服务。它为自行收集 run 的调用方保留底层 `start(name, request)` 和 `resume(name, request)`,并公开 `startContinuable(spec)` 与 `sendMessage(...)`,用于具备持久性、由 Task 支撑的激活。系统删除独立的 `@deepseek-ai/dsh-subagent-control` 包(package)和 `ctx.subagentControl` 键;可选的 `@deepseek-ai/dsh-tool-subagent-control` 包则直接注入 `ctx.subagents`。 + +继续执行的实现仍是内部管理器,不会扩展提供方注册表的核心状态。`SubagentService` 通过 `ctx.inject(['tasks', 'agents'], ...)` 创建该管理器,因此注入的 Cordis child fiber 拥有自身的 Task 完成监听器和拆卸 effect。加载提供方注册表不要求 Task 或持久化。只有 Task 和 Agent 可用时,该管理器才会存在;每项继续执行操作都在需要持久性时解析会话持久化服务。dispose(资源释放)该 fiber 会先取消并结算活跃的继续执行,再释放其关联。 + +`startContinuable` 与底层 `start` 保持分离,因为二者的所有权与时序契约不同:前者分配持久化 child id、创建 Task,并同步返回两个 id,而启动过程继续在 Task 内运行;底层 `start` 则等待提供方发布,并移交一个由持有方负责的 run。若通过标志或返回值联合类型将该方法并入 `start`,会扩大底层契约,改动反而多于保留现有的显式入口。 + +每个 `@deepseek-ai/dsh-tool-subagent` 实例都会选择 `backgroundMode: 'one-shot' | 'continuable'`,默认值为 `one-shot`。这项配置表示策略;`provider.resume` 只用于检查所配置的可继续模式是否受提供方支持。因此,可恢复的提供方仍可执行一次性后台工作。`send_message` 工具是独立适配器:加载或省略该工具既不会启用也不会禁用 `startContinuable`。 + +## 已考虑的替代方案 + +**保留独立服务。** 这样能保持最严格的依赖分离,但每条生产环境中的可继续路径都要组合两个服务,而额外的公开键会暴露调用方并不需要的架构差异。内部管理器无需第二个服务,也能保留可选的 Task 和持久化依赖。 + +**根据 `provider.resume` 推断可继续模式。** 方法是否存在可以准确表示从持久化存储恢复的功能,却不能表示部署策略。这会迫使每个可恢复的提供方都采用可继续后台语义,并使配套插件缺失成为运行时错误。显式的工具配置将选择与功能分离。 + +**注册继续执行访问入口,或检查后续操作工具。** 注册表可以告诉委派工具继续执行接口是否存在,但启动具备持久性的工作不需要任何后续操作适配器。这样的注册表会把 UI 组合编码进执行策略,并以另一个名称重新建立插件间依赖关系。 + +**将底层启动与可继续启动合并为一个方法。** `start` 上的标志会使该方法返回就绪的 run,或立即返回 Task 和 child 标识,从而削弱简单的所有权边界。保留 `startContinuable` 改动更小,也能明确保留两项契约。 + +## 影响 + +- 服务拓扑少了一个公开键和一个包,同时底层提供方分发仍可在没有 Task 或持久化时使用。 +- 配置的提供方缺少 `resume` 时,可继续模式会在提供方挂载阶段失败;缺少 Task、Agent 或持久化时,仍会在需要它们的最早操作处失败。 +- 后续消息投递仍为可选功能。部署可以通过 Task 工具启动并收集可继续工作,而不公开 `send_message`。 +- `dsh-subagent` 包内的继续执行管理器仍然感知 Task 和持久化,因此该包会将这些服务声明为可选的对等依赖(peer dependency),即使普通的 `start` 和 `resume` 调用方并不需要它们。 +- 现有的继续执行竞态、授权、持久性、取消及先结算再 dispose 的语义均保持不变,并继续由迁移后的 `subagent` 测试固定。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 76844279f6..189a8af496 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -94,8 +94,6 @@ flowchart LR cfg --> plugin_tui_subagent_spawn plugin_tui_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_tui_subagent_fork - plugin_tui_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"] - cfg --> plugin_tui_subagent_control plugin_tui_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_tui_tool_subagent_control plugin_tui_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] @@ -189,7 +187,6 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | -| `subagent-control` | `@deepseek-ai/dsh-subagent-control` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 4982a521a7..891e2b359f 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -257,12 +257,8 @@ config: providerName: fork -# Continuable background children: the control service owns durable child ids -# and Task-backed activations; the control tool registers the one global -# `send_message` shared by both delegation tools. -- id: subagent-control - name: '@deepseek-ai/dsh-subagent-control' - +# Continuable background children are selected per delegation tool. The +# separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' @@ -271,12 +267,14 @@ config: provider: spawn toolName: subagent + backgroundMode: continuable - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork + backgroundMode: continuable - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' diff --git a/apps/cli/package.json b/apps/cli/package.json index 4cfdf29725..e9c719dce7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -101,7 +101,6 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-control": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d7d4618aac..2d14dedd68 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: d7beb60baac3c550eb008d414158d9a05181337a -architecture.zh.md: 200f82df3d936b45f4aeef0cb080c55483af602a +architecture.md: 44be3b55ab5061490a2ceb632175c1bb53a21330 +architecture.zh.md: d803bea1ba39e8fd07a01446dd2d2ae53aca35e1 diff --git a/docs/architecture.md b/docs/architecture.md index d7beb60baa..44be3b55ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,8 +38,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry, progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction, optional model-free result pruning | -| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | -| `ctx.subagentControl` | [`subagent/`](../packages/subagent/README.md) | continuable-child Task-backed activation and steer-or-resume routing | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers plus optional Task-backed continuation and steer-or-resume routing | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 200f82df3d..d803bea1ba 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -38,8 +38,7 @@ | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill(技能)提供方注册表和渐进式披露 | | `ctx.web` | [`web/`](../packages/web/README.md) | 搜索与抓取提供方注册表 | | `ctx.compact`,`ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction)和可选的无模型结果裁剪 | -| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方 | -| `ctx.subagentControl` | [`subagent/`](../packages/subagent/README.md) | 可继续子 agent 的 Task 化 activation,以及 steer 或恢复路由 | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方,以及可选的由 Task 支撑的继续执行与 steer-or-resume 路由 | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | 落日志的 plan 协作状态 | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 946091c2c7..2681bd728a 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -132,12 +132,11 @@ flowchart LR pkg_compact["compact"] svc_compact["ctx.compact
Compaction seam"] pkg_subagent["subagent"] - svc_subagents["ctx.subagents
Subagent provider registry"] + svc_subagents["ctx.subagents
Subagent provider and continuation service"] pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] - pkg_tool_ralph["tool-ralph"] - svc_subagentControl["ctx.subagentControl
Continuable-subagent control service"] pkg_tool_subagent_control["tool-subagent-control"] + pkg_tool_ralph["tool-ralph"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
Background task registry"] pkg_tasks_local["tasks-local"] @@ -225,7 +224,6 @@ flowchart LR pkg_storage_domain --> svc_storageDomain pkg_storage_json --> svc_storage pkg_storage_sqlite --> svc_storage - pkg_subagent --> svc_subagentControl pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents @@ -314,10 +312,9 @@ flowchart LR svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain svc_storageDomain --> pkg_workspace - svc_subagentControl --> pkg_tool_subagent - svc_subagentControl --> pkg_tool_subagent_control svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent + svc_subagents --> pkg_tool_subagent_control svc_subprocess --> pkg_bash_local svc_subprocess --> pkg_bash_sandbox svc_subprocess --> pkg_lsp_local @@ -394,8 +391,7 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route. | -| `ctx.subagentControl` | `core` | [`subagent`](../packages/subagent/subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | - | Binds one durable child session to Task-backed activations over ctx.subagents; tool-subagent starts continuable background children and tool-subagent-control delivers follow-up messages. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Task-backed continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7980ef4136..c2158222ee 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1893,6 +1893,12 @@ export interface Config { * parameter and reject forced background calls. */ enableRunInBackground?: boolean + /** + * Background execution policy (default `one-shot`). `continuable` requires + * a provider with persisted resume support and returns both child and Task + * ids; follow-up adapters remain independently optional. + */ + backgroundMode?: 'one-shot' | 'continuable' /** * Agent options applied to every child; omitted fields use child-loop defaults. */ @@ -1928,7 +1934,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:27`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:25`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-tasks` @@ -2343,12 +2349,11 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) -- `@deepseek-ai/dsh-subagent-control` — requires `subagents` · `tasks` · `agents` ([`packages/subagent/subagent-control/src/index.ts`](../packages/subagent/subagent-control/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) - `@deepseek-ai/dsh-tasks-local` ([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) -- `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagentControl` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) +- `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 724867f0d8..db6af68970 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -795,7 +795,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:150`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -812,7 +812,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:124`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -827,7 +827,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:130`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -849,7 +849,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:141`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:156`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 43965ad9a3..b0d07711f2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1946,56 +1946,30 @@ async closeAll(): Promise Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/storage/storage-domain/src/index.ts) -## `ctx.subagentControl` — `SubagentControlService` +## `ctx.subagents` — `SubagentService` -The continuable-subagent orchestration service. Tool schema and UI adapters are consumers of this one contract: parent and human messages route through sendMessage and share one activation result and cancellation boundary, while foreground one-shot delegation keeps calling `ctx.subagents.start()` directly. +Named provider registry with raw and Task-backed continuation operations. ```ts cordis-catalog /** - * Start a continuable background child: allocate its stable session id, - * snapshot its durable descriptor, and register the initial activation's - * Task. A synchronous validation failure (a non-JSON descriptor input, - * missing persistence, Task preflight) throws without creating a Task; the - * method otherwise returns both identities immediately, without waiting for - * child publication or descriptor durability. Asynchronous startup failure - * settles the returned Task as `failed` (or `killed` when cancelled) after - * any published run is disposed, which can leave an unmaterialized child id - * that later by-id operations report as unavailable. - * @param spec - provider, Task label, and the delegation request. - * @returns the stable child id and the initial activation's Task id. + * Start one durable continuable child through a Task-backed initial + * activation. + * @param spec - provider, Task label, and delegation request. + * @returns the stable child id and initial activation Task id. */ startContinuable(spec: ContinuableStartSpec): ContinuableStart /** - * Deliver one message to a known continuable child: steer its running - * activation, or cold-resume the durable session into a fresh Task-backed - * activation. The two routes are reported distinctly so timing-dependent - * routing is observable. Rejection means the message was NOT delivered — in - * particular, losing a race with Task settlement does not fall through to - * cold resume within the same call; a later retry after Task terminal may - * start the next activation. The started Task owns descriptor lookup and - * direct-parent authorization (its AbortSignal exists before that lookup), - * so an unknown, foreign, or descriptor-less child settles the started Task - * as `failed` with a detail reporting the id as unavailable. - * @param parent - the live parent agent sending the message (model tool or - * human adapter); Task access is authorized by its session id. - * @param childId - the stable child session id. - * @param message - the user-role content to deliver. - * @param source - caller-supplied attribution retained across either route. - * @returns whether the message `steered` the existing Task or `started` a new one. + * Deliver a message to a continuable child by steering its live activation + * or cold-resuming a fresh Task-backed activation. + * @param parent - live direct parent authorizing the operation. + * @param childId - durable child session id. + * @param message - user-role content to deliver. + * @param source - durable caller attribution. + * @returns the existing steered Task or newly started Task. */ -async sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise -``` +sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) - -Source: [`packages/subagent/subagent-control/src/index.ts:176`](../../packages/subagent/subagent-control/src/index.ts) - -## `ctx.subagents` — `SubagentService` - -Named provider registry and capability-checked start surface. - -```ts cordis-catalog /** * Register a provider under its name. Registration is effect-scoped and HMR * safe; removing a provider blocks new starts but does not revoke runs that @@ -2032,7 +2006,7 @@ async start(name: string, request: SubagentStartRequest): Promise /** * Resume a persisted continuable child through the named provider's * `resume` capability, with the same run lifecycle observation as - * {@link start}. The caller (the control service) has already loaded the + * {@link start}. The internal continuation manager has already loaded the * child, folded its descriptor, and authorized the parent; this method owns * only capability-checked dispatch. * @param name - the provider recorded in the child's descriptor. @@ -2042,9 +2016,9 @@ async start(name: string, request: SubagentStartRequest): Promise async resume(name: string, request: SubagentResumeRequest): Promise ``` -Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentResumeRequest](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentResumeRequest](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:191`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:206`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index acd0dea472..9864df25bd 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,9 +4,9 @@ English | [中文](subagent.zh.md) The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the global `send_message`). Continuable-child orchestration lives on `ctx.subagentControl` in [dsh-subagent-control](../../packages/subagent/subagent-control). The proposals and rationale: [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) and [the continuable background subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`). The same `ctx.subagents` service owns continuable-child orchestration through an internal Task-backed manager. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable background subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). -Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) and [`packages/subagent/subagent-control/src/index.ts`](../../packages/subagent/subagent-control/src/index.ts) +Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) ## Two kinds of capability, discovered two ways @@ -90,7 +90,7 @@ interface SubagentStartRequest { */ readonly persona?: string /** - * Continuable-child intent, resolved by the control service before start. + * Continuable-child intent, resolved by `ctx.subagents` before start. * The provider MUST publish exactly `sessionId` as the child identity * instead of allocating one internally, and MUST append the snapshotted * `descriptor` as the child's turn-enclosed `subagent/descriptor` event @@ -105,7 +105,7 @@ interface SubagentStartRequest { ## Continuable children: `SubagentContinuation` and `SubagentResumeRequest` -A **continuable background subagent** is a durable child session with a series of Task-backed activations. `ctx.subagentControl` (`SubagentControlService` in [dsh-subagent-control](../../packages/subagent/subagent-control)) allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the resolved start request; the provider publishes exactly that id and appends the descriptor inside the child's first turn. On follow-up, the control service loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches a fully resolved resume request through `SubagentService.resume()` to `SubagentProvider.resume()`. The seam stays Task- and persistence-agnostic — descriptor lookup and Task association live only in the control service. `startContinuable()` returns a `ContinuableStart` (both identities), and `sendMessage()` returns a `SendMessageResult` reporting whether the message `steered` the running activation's existing Task or `started` a fresh one. Every sender supplies a `MessageSource`; the model-facing tool uses `CoordinatorMessageSource`, while a human adapter uses `{ kind: 'user' }`. Both project to a user-role model message, but the durable source remains distinct for policy and title consumers. +A **continuable background subagent** is a durable child session with a series of Task-backed activations. `SubagentService.startContinuable()` allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the resolved start request; the provider publishes exactly that id and appends the descriptor inside the child's first turn. `SubagentService.sendMessage()` loads and authorizes a stopped child before dispatching a fully resolved resume request through the raw `resume()` operation, or steers its live activation. An internal manager owns descriptor lookup and Task association only while `ctx.tasks` and `ctx.agents` exist; persistence is required per continuation operation, not to load the provider registry. `startContinuable()` returns both identities, while `sendMessage()` reports whether the message `steered` the existing Task or `started` a fresh one. Every sender supplies a `MessageSource`; the optional model-facing tool uses `CoordinatorMessageSource`, while a human adapter uses `{ kind: 'user' }`. ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -119,10 +119,10 @@ interface CoordinatorMessageSource { ```ts type-equiv /** * The resolved continuable-child identity and durable composition record a - * control-service caller attaches to a start request. + * continuation caller attaches to a start request. */ interface SubagentContinuation { - /** Control-allocated stable child session id, published verbatim. */ + /** Service-allocated stable child session id, published verbatim. */ readonly sessionId: SessionId /** Snapshotted descriptor persisted in the child log for cold resume. */ readonly descriptor: SubagentDescriptorData @@ -132,7 +132,7 @@ interface SubagentContinuation { ```ts type-equiv /** * What a caller asks for when resuming a persisted continuable child. The - * control service loads the child log, folds and authorizes its descriptor, + * continuation manager loads the child log, folds and authorizes its descriptor, * and passes this fully resolved request to * {@link SubagentService.resume}, which dispatches to * {@link SubagentProvider.resume}. The provider reconstructs the declared diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7fc4f549f2..204bae369d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:150`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:124`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:141`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index d880453727..3e6889c80e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,7 +65,6 @@ flowchart TD subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] pkg_subagent_acp["subagent-acp"] - pkg_subagent_control["subagent-control"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] @@ -740,6 +739,8 @@ flowchart TD pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope pkg_subagent --> pkg_session + pkg_subagent --> pkg_session_persistence + pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm @@ -892,13 +893,6 @@ flowchart TD pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subprocess - pkg_subagent_control --> pkg_agent - pkg_subagent_control --> pkg_invariants - pkg_subagent_control --> pkg_llm - pkg_subagent_control --> pkg_session - pkg_subagent_control --> pkg_session_persistence - pkg_subagent_control --> pkg_subagent - pkg_subagent_control --> pkg_tasks pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -908,6 +902,17 @@ flowchart TD pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools pkg_subagent_inprocess --> pkg_user_approval + pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_invariants + pkg_tool_subagent --> pkg_llm + pkg_tool_subagent --> pkg_subagent + pkg_tool_subagent --> pkg_tasks + pkg_tool_subagent --> pkg_tools + pkg_tool_subagent_control --> pkg_invariants + pkg_tool_subagent_control --> pkg_llm + pkg_tool_subagent_control --> pkg_session + pkg_tool_subagent_control --> pkg_subagent + pkg_tool_subagent_control --> pkg_tools pkg_repository_plugin --> pkg_invariants pkg_repository_plugin --> pkg_mcp_client pkg_repository_plugin --> pkg_paths @@ -1014,18 +1019,6 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess - pkg_tool_subagent --> pkg_agent - pkg_tool_subagent --> pkg_invariants - pkg_tool_subagent --> pkg_llm - pkg_tool_subagent --> pkg_subagent - pkg_tool_subagent --> pkg_subagent_control - pkg_tool_subagent --> pkg_tasks - pkg_tool_subagent --> pkg_tools - pkg_tool_subagent_control --> pkg_invariants - pkg_tool_subagent_control --> pkg_llm - pkg_tool_subagent_control --> pkg_session - pkg_tool_subagent_control --> pkg_subagent_control - pkg_tool_subagent_control --> pkg_tools pkg_jsonrpc --> pkg_agent pkg_jsonrpc --> pkg_invariants pkg_jsonrpc --> pkg_llm @@ -1199,7 +1192,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1226,8 +1219,9 @@ flowchart TD | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`subagent-control`](../packages/subagent/subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | @@ -1240,8 +1234,6 @@ flowchart TD | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-control`](../packages/subagent/subagent-control), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent-control`](../packages/subagent/subagent-control), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 0a316bc636..87e9a2a526 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -31,7 +31,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`. | -| `@deepseek-ai/dsh-tool-subagent-control` | `send_message` | `ctx.tools`, `ctx.subagentControl` | `tool/call`, `tool/result`, `child session events through the control service` | - | The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once. | +| `@deepseek-ai/dsh-tool-subagent-control` | `send_message` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -1117,7 +1117,7 @@ The five read-only tools hide provider cursors and authorize every result from t ### `subagent` -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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. +Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. ```json { @@ -1133,7 +1133,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 22adf09555..9524184414 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -39,8 +39,6 @@ flowchart LR cfg --> plugin_acp_subagent_spawn plugin_acp_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_acp_subagent_fork - plugin_acp_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"] - cfg --> plugin_acp_subagent_control plugin_acp_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_acp_tool_subagent_control plugin_acp_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] @@ -83,7 +81,6 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | -| `subagent-control` | `@deepseek-ai/dsh-subagent-control` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index e45c56c92d..76cac9cbb8 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -96,12 +96,8 @@ config: providerName: fork -# Continuable background children: the control service owns durable child ids -# and Task-backed activations; the separately loaded control tool registers the -# one global `send_message` shared by both delegation tools. -- id: subagent-control - name: '@deepseek-ai/dsh-subagent-control' - +# Continuable background children are selected per delegation tool. The +# separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' @@ -110,6 +106,7 @@ config: provider: spawn toolName: subagent + backgroundMode: continuable maxDepth: 1 - id: tool-subagent-fork @@ -117,6 +114,7 @@ config: provider: fork toolName: subagent_fork + backgroundMode: continuable maxDepth: 1 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 7df756ee99..e386a25eff 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -122,22 +122,22 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */ + /** 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ run_in_background?: boolean; } & Record; - /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */ + /** 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 5f8e31fe9b..00ae670288 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -276,7 +276,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -290,7 +290,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ @@ -301,7 +301,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -315,7 +315,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 1a7e813d7c..6d052ead19 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -219,7 +219,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -233,7 +233,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ @@ -244,7 +244,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -258,7 +258,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 31e8cdce23..15b5e8dde6 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -105,22 +105,22 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */ + /** 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ run_in_background?: boolean; } & Record; - /** 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */ + /** 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 517c9b1d71..6124557f08 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -235,7 +235,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -249,7 +249,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ @@ -260,7 +260,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -274,7 +274,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index abc3e13256..d4c004034f 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -198,7 +198,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ @@ -223,7 +223,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index 2ac976d621..72c6b74b72 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -402,7 +402,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -416,7 +416,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ @@ -427,7 +427,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -441,7 +441,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index 47439bfdb0..bf0103bbeb 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -198,7 +198,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ @@ -223,7 +223,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index d1a60f6f92..d143e9d82a 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -198,7 +198,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ @@ -223,7 +223,7 @@ }, { "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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", + "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index ecf343a264..0a3da81e04 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -37,8 +37,6 @@ flowchart LR cfg --> plugin_headless_subagent_spawn plugin_headless_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_headless_subagent_fork - plugin_headless_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"] - cfg --> plugin_headless_subagent_control plugin_headless_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_headless_tool_subagent_control plugin_headless_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] @@ -74,7 +72,6 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | -| `subagent-control` | `@deepseek-ai/dsh-subagent-control` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 73673aee8d..4caf63672f 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -86,12 +86,8 @@ config: providerName: fork -# Continuable background children: the control service owns durable child ids -# and Task-backed activations; the control tool registers the one global -# `send_message` shared by both delegation tools. -- id: subagent-control - name: '@deepseek-ai/dsh-subagent-control' - +# Continuable background children are selected per delegation tool. The +# separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' @@ -100,6 +96,7 @@ config: provider: spawn toolName: subagent + backgroundMode: continuable maxDepth: 1 - id: tool-subagent-fork @@ -107,6 +104,7 @@ config: provider: fork toolName: subagent_fork + backgroundMode: continuable maxDepth: 1 # The worker-thread workflow engine fans a model-written JavaScript script's diff --git a/examples/package.json b/examples/package.json index fb105d2ad6..bcbb4e21f0 100644 --- a/examples/package.json +++ b/examples/package.json @@ -63,7 +63,6 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", - "@deepseek-ai/dsh-subagent-control": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 43818cd6b7..19f7707262 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -881,23 +881,17 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ ], }, { - key: 'subagentControl', - summary: 'The continuable-subagent orchestration service.', + key: 'subagents', + summary: 'Named provider registry with raw and Task-backed continuation operations.', methods: [ { signature: 'startContinuable(spec: ContinuableStartSpec): ContinuableStart', - jsDoc: '/**\n * Start a continuable background child: allocate its stable session id,\n * snapshot its durable descriptor, and register the initial activation\'s\n * Task. A synchronous validation failure (a non-JSON descriptor input,\n * missing persistence, Task preflight) throws without creating a Task; the\n * method otherwise returns both identities immediately, without waiting for\n * child publication or descriptor durability. Asynchronous startup failure\n * settles the returned Task as `failed` (or `killed` when cancelled) after\n * any published run is disposed, which can leave an unmaterialized child id\n * that later by-id operations report as unavailable.\n * @param spec - provider, Task label, and the delegation request.\n * @returns the stable child id and the initial activation\'s Task id.\n */', + jsDoc: '/**\n * Start one durable continuable child through a Task-backed initial\n * activation.\n * @param spec - provider, Task label, and delegation request.\n * @returns the stable child id and initial activation Task id.\n */', }, { - signature: 'async sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise', - jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. Rejection means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the user-role content to deliver.\n * @param source - caller-supplied attribution retained across either route.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */', + signature: 'sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise', + jsDoc: '/**\n * Deliver a message to a continuable child by steering its live activation\n * or cold-resuming a fresh Task-backed activation.\n * @param parent - live direct parent authorizing the operation.\n * @param childId - durable child session id.\n * @param message - user-role content to deliver.\n * @param source - durable caller attribution.\n * @returns the existing steered Task or newly started Task.\n */', }, - ], - }, - { - key: 'subagents', - summary: 'Named provider registry and capability-checked start surface.', - methods: [ { signature: 'registerProvider(provider: SubagentProvider): () => void', jsDoc: '/**\n * Register a provider under its name. Registration is effect-scoped and HMR\n * safe; removing a provider blocks new starts but does not revoke runs that\n * were already returned to their holders.\n * @param provider - the trusted provider implementation.\n * @returns the exact Cordis effect disposer.\n */', @@ -916,7 +910,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async resume(name: string, request: SubagentResumeRequest): Promise', - jsDoc: '/**\n * Resume a persisted continuable child through the named provider\'s\n * `resume` capability, with the same run lifecycle observation as\n * {@link start}. The caller (the control service) has already loaded the\n * child, folded its descriptor, and authorized the parent; this method owns\n * only capability-checked dispatch.\n * @param name - the provider recorded in the child\'s descriptor.\n * @param request - the fully resolved resume request.\n * @returns the fresh holder-owned run for the resumed activation.\n */', + jsDoc: '/**\n * Resume a persisted continuable child through the named provider\'s\n * `resume` capability, with the same run lifecycle observation as\n * {@link start}. The internal continuation manager has already loaded the\n * child, folded its descriptor, and authorized the parent; this method owns\n * only capability-checked dispatch.\n * @param name - the provider recorded in the child\'s descriptor.\n * @param request - the fully resolved resume request.\n * @returns the fresh holder-owned run for the resumed activation.\n */', }, ], }, diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index e72bb72c8a..139eae3bde 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -209,13 +209,12 @@ config: id: 'subagent', summary: 'Delegate work to child agents', mode: 'multiple', - // The control pair rides every resumable in-process option: background - // delegation on spawn/fork is continuable and advertises send_message. + // In-process options select continuable background delegation; the + // follow-up adapter remains an independently loadable global tool. baseResources: [ { kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks' }, { kind: 'npm-cordis-config-entry', id: 'tool-tasks', package: '@deepseek-ai/dsh-tool-tasks' }, { kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }, - { kind: 'npm-cordis-config-entry', id: 'subagent-control', package: '@deepseek-ai/dsh-subagent-control' }, { kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' }, ], options: [ @@ -229,7 +228,7 @@ config: kind: 'npm-cordis-config-entry', id: 'tool-subagent', package: '@deepseek-ai/dsh-tool-subagent', - config: { provider: 'spawn' } satisfies ToolSubagentConfig, + config: { provider: 'spawn', backgroundMode: 'continuable' } satisfies ToolSubagentConfig, }, ], }, @@ -242,7 +241,11 @@ config: kind: 'npm-cordis-config-entry', id: 'tool-subagent-fork', package: '@deepseek-ai/dsh-tool-subagent', - config: { provider: 'fork', toolName: 'subagent_fork' } satisfies ToolSubagentConfig, + config: { + provider: 'fork', + toolName: 'subagent_fork', + backgroundMode: 'continuable', + } satisfies ToolSubagentConfig, }, ], }, diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 0491b589c6..bead24d34c 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/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/subagent/README.md -README.md: 438907ea7de41842f900b050385f15feac7cc272 -README.zh.md: 87911216bc4e6b5f75e17ca2c58818725f66e7ec +README.md: a195ecbaeb24cb63af8cdd4ac872bb6a2fc97d46 +README.zh.md: b9965030a38b603f7c03d98d6b8021acbeb47fda diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 438907ea7d..a195ecbaeb 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -6,16 +6,15 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | Package | Role | ctx key | |---|---|---| -| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary + the durable child descriptor | `ctx.subagents` | +| `subagent/` | Subagent service: named-provider registry, vocabulary, durable descriptor, and optional Task-backed continuation orchestration | `ctx.subagents` | | `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — | | `subagent-spawn/` | In-process backend: a fresh child agent, with cold resume | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) | | `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | -| `subagent-control/` | Continuable-child orchestration: stable ids, descriptor lookup, Task-backed activation, steer-or-resume routing | `ctx.subagentControl` | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -| `tool-subagent-control/` | The one globally named `send_message` follow-up tool over `ctx.subagentControl` | (registers on `ctx.tools`) | +| `tool-subagent-control/` | The optional, globally named `send_message` follow-up tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). `subagent-control` sits above the seam: it binds one durable child session to a series of disposable Task-backed activations, and both model tools and human-facing adapters route through its one contract. Tests replace only the child boundary with package-local fixtures. +The interface and continuation orchestration live at `subagent/subagent/`. Raw `start` / `resume` dispatch stays independent of Tasks and persistence; an internal manager binds durable child sessions to disposable Task-backed activations only while the Task and Agent services are present, and resolves persistence only when a continuation operation runs. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. -The proposals and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) and [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md). +The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 87911216bc..b9965030a3 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -6,16 +6,15 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | 包(package) | 角色 | ctx 键 | |---|---|---| -| `subagent/` | 抽象 subagent seam:具名提供方注册表、词汇与持久化子 agent 描述符 | `ctx.subagents` | +| `subagent/` | Subagent 服务:具名提供方注册表、词汇、持久化描述符与可选的由 Task 支撑的继续执行编排 | `ctx.subagents` | | `subagent-inprocess/` | 共享进程内运行驱动器(不含提供方;每次运行使用一个清理 effect) | 无 | | `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) | | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) | | `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) | | `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | -| `subagent-control/` | 可继续子 agent 编排:稳定 ID、描述符查找、由 Task 支撑的 activation,以及 steer 或恢复路由 | `ctx.subagentControl` | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | -| `tool-subagent-control/` | 基于 `ctx.subagentControl`、全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools`) | +| `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools`) | -接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。`subagent-control` 位于该 seam 之上:它把一个持久化子会话绑定到一系列可 dispose、由 Task 支撑的 activation,模型工具和面向人的适配器都通过这份统一契约进行路由。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口和继续执行编排位于 `subagent/subagent/`。原始 `start` / `resume` 分发仍与 Task 和持久化无关;只有在 Task 与 Agent 服务存在时,内部管理器才会把持久化子会话绑定到可 dispose、由 Task 支撑的 activation,并且只在继续执行操作运行时解析持久化服务。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 -提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) 和 [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)。 +设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/subagent-control/README.md b/packages/subagent/subagent-control/README.md deleted file mode 100644 index d1afd86687..0000000000 --- a/packages/subagent/subagent-control/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# @deepseek-ai/dsh-subagent-control - -The continuable-subagent control service (`ctx.subagentControl`): the one orchestration path that binds a durable child session to a series of disposable Task-backed activations. Model tools and human-facing adapters call the same contract; the low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic. - -## Activation lifecycle - -A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output. - -`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's confirmed `steer` capability and returns the existing Task id (`steered`) only after a committed request snapshot admits the message; an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Rejection means the message was not delivered: terminal policy or Task settlement winning the admission race never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. - -Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface). - -The activation association is process-local routing state, installed before any persistence or provider await and removed after run disposal and Task terminal publication. It is not a durable catalog: restart recovers the child session, not in-flight Tasks or their notifications. - -## Model Experience - -### Task completion and output - -#### What the model sees - -None directly, as this package registers no tool and no prompt text; the model observes continuable children through `@deepseek-ai/dsh-tool-subagent`'s background acknowledgement, `@deepseek-ai/dsh-tool-subagent-control`'s `send_message` results, and the generic task surface, whose outputs this service produces. - -#### Token effect - -None beyond the consuming tools' own results. - -#### KV Cache effect - -None; this service appends nothing to any model-visible sequence. - -## Known Limitations and Deferred Work - -- **Concurrent stopped-child admission is not atomic across awaits** — the synchronous association install admits one activation per child in this process, but a caller bypassing the control service can still race it; the Agent registry's same-id collision is the final backstop, and the losing Task fails with its message not delivered. -- **The association coordinates only one runtime** — concurrent resume from multiple processes needs a persistence-level lease or compare-and-set, which no backend offers yet. -- **Task records are process-local** — restart recovers the durable child session, not an interrupted Task, its result, or its completion notice; durable Task recovery is a separate concern. -- **Human interaction requires the exact live parent Agent** — Task access is fenced by the owner session and owner disposal cancels its Tasks; standalone child conversations belong to the interactive-side-sessions proposal, not this Task-owned lifecycle. -- **ACP children remain one-shot** — `AcpProvider.resume` and per-child continuation advertisement are deferred until the remote-session descriptor contract is resolved. diff --git a/packages/subagent/subagent-control/package.json b/packages/subagent/subagent-control/package.json deleted file mode 100644 index 9522bca29e..0000000000 --- a/packages/subagent/subagent-control/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-subagent-control", - "description": "Continuable-subagent control service: Task-backed activation, durable child descriptors, and steer-or-resume message routing", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-tasks": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-fork": "workspace:^", - "@deepseek-ai/dsh-subagent-spawn": "workspace:^", - "@deepseek-ai/dsh-tasks": "workspace:^", - "@deepseek-ai/dsh-tasks-local": "workspace:^", - "@deepseek-ai/dsh-tool-tasks": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/subagent/subagent-control/src/invariant.ts b/packages/subagent/subagent-control/src/invariant.ts deleted file mode 100644 index ce40f360ca..0000000000 --- a/packages/subagent/subagent-control/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-control`. - * @module @deepseek-ai/dsh-subagent-control/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-control' - -/** Cordis companion plugin name. */ -export const name = 'subagent-control-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the activation association is deliberately private - * process-local routing state with no event stream of its own; the run - * lifecycle pair it participates in is checked by `@deepseek-ai/dsh-subagent`, - * and Task lifecycle relations belong to `@deepseek-ai/dsh-tasks`. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-control/tsconfig.json b/packages/subagent/subagent-control/tsconfig.json deleted file mode 100644 index d41aacf4fb..0000000000 --- a/packages/subagent/subagent-control/tsconfig.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../core/session" - }, - { - "path": "../../session-persistence/session-persistence" - }, - { - "path": "../subagent" - }, - { - "path": "../../tasks/tasks" - }, - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index d08d18e6e3..af8a2f7b71 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -10,11 +10,10 @@ The family separates the stable interface from implementations and model-facing | Package | Role | |---|---| -| `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, and lifecycle events. | +| `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, lifecycle events, and continuable-child orchestration. | | `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child, with cold resume. | | `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns, with cold resume. | | `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | -| `@deepseek-ai/dsh-subagent-control` | Continuable-child orchestration: durable ids, descriptors, Task-backed activation. | | `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. | | `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. | @@ -22,7 +21,7 @@ Multiple providers may coexist under different names. This lets a deployment exp ## Service API -`SubagentService` has five main operations: +`SubagentService` has seven main operations: | Member | Meaning | |---|---| @@ -30,7 +29,9 @@ Multiple providers may coexist under different names. This lets a deployment exp | `getProvider(name)` | Return the provider, or `undefined` when absent. | | `list()` | Return provider names in insertion order. | | `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. | -| `resume(name, request)` | Capability-checked dispatch to `provider.resume?()` with the same run lifecycle observation as `start`. The caller (the control service) has already loaded the child, folded its descriptor, and authorized the parent; this seam stays collection-, Task-, and persistence-agnostic. | +| `resume(name, request)` | Capability-checked raw dispatch to `provider.resume?()` with the same run lifecycle observation as `start`; the caller owns descriptor lookup, authorization, and collection. | +| `startContinuable(spec)` | Allocate a durable child id and register its initial Task-backed activation. Requires `ctx.tasks`, `ctx.agents`, session persistence, and a resumable provider. | +| `sendMessage(parent, childId, message, source)` | Steer the current activation or start a new Task that cold-resumes the durable child. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. | `SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, set a child persona, or carry a resolved `continuation` (the control-allocated stable child id plus its durable descriptor), which requires the provider's `resume` capability. @@ -63,7 +64,7 @@ The seam owns the depth vocabulary shared by implementations and consumers: the `SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. For a continuable activation, a completed result also confirms that the provider made its final state durable; a failed required checkpoint rejects as infrastructure rather than publishing unconfirmed output. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. -A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the control-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. +A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the service-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. The service emits `subagent/start` only after `start()` or `resume()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names. @@ -73,7 +74,7 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. Background delegation does not change this seam; `@deepseek-ai/dsh-subagent-control` registers each activation with the generic `ctx.tasks` runtime, then collection and cancellation use the shared task tools. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. +The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool. Continuable background delegation calls `ctx.subagents.startContinuable()`, whose internal manager exists only while `ctx.tasks` and `ctx.agents` are available; session persistence is resolved per continuation operation. Collection and cancellation use the shared task tools. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. ## Model Experience diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 58b51d1888..5f9f617eae 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -33,9 +33,19 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-persistence": { + "optional": true + }, + "@deepseek-ai/dsh-tasks": { + "optional": true + } + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", @@ -43,6 +53,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent/src/continuation.ts similarity index 92% rename from packages/subagent/subagent-control/src/index.ts rename to packages/subagent/subagent/src/continuation.ts index e8a609e2d6..533cf15332 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -1,10 +1,7 @@ /** - * Continuable-subagent control service (`ctx.subagentControl`): stable child - * ids, descriptor persistence and lookup by known child id, Task-backed - * activation, and steer-or-resume message routing. The low-level - * `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic; - * this service owns the policy that binds one durable child session to a - * series of disposable Task-backed activations. + * Internal continuable-subagent manager: stable child ids, descriptor + * persistence and lookup by known child id, Task-backed activation, and + * steer-or-resume message routing behind `ctx.subagents`. * * Every continuable activation — initial or resumed, parent- or human-started * — has exactly one Task and one result. Task settlement awaits the child @@ -13,26 +10,21 @@ * targets the whole activation: parent and human messages that joined one * turn share its result and its `killed` outcome. * - * @module @deepseek-ai/dsh-subagent-control + * @module @deepseek-ai/dsh-subagent */ import { randomUUID } from 'node:crypto' -import { Context, Service } from 'cordis' +import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { foldSubagentDescriptor, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' -import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' +import type { SubagentResult, SubagentRun, SubagentStartRequest } from './types.ts' +import type { SubagentService } from './index.ts' import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks' -declare module 'cordis' { - interface Context { - subagentControl: SubagentControlService - } -} - /** Attribution for a model coordinator's follow-up to one of its children. */ export interface CoordinatorMessageSource { readonly kind: 'coordinator' @@ -46,7 +38,7 @@ declare module '@deepseek-ai/dsh-llm' { } } -/** Typed error for control-service routing, authorization, and delivery failures. */ +/** Typed error for continuation routing, authorization, and delivery failures. */ export class SubagentControlError extends HarnessError { constructor(message: string, code: string, options?: ErrorOptions) { super(message, code, options) @@ -68,7 +60,7 @@ export interface ContinuableStartSpec { readonly request: Omit } -/** Identities returned by {@link SubagentControlService.startContinuable}. */ +/** Identities returned by {@link SubagentContinuationManager.startContinuable}. */ export interface ContinuableStart { /** The durable child session id, stable across activations. */ readonly childId: SessionId @@ -77,7 +69,7 @@ export interface ContinuableStart { } /** - * How {@link SubagentControlService.sendMessage} delivered a message: + * How {@link SubagentContinuationManager.sendMessage} delivered a message: * `steered` joined the running activation's existing Task without creating a * Task of its own; `started` created a fresh Task that cold-resumes the * durable child with the message. Failure is an exception, never a result — @@ -173,14 +165,14 @@ function finalText(blocks: ContentBlock[]): string { * boundary, while foreground one-shot delegation keeps calling * `ctx.subagents.start()` directly. */ -export class SubagentControlService extends Service { - static inject = ['subagents', 'tasks', 'agents'] - +export class SubagentContinuationManager { /** Child session id → its current activation. Process-local, never durable. */ private activations = new Map() - constructor(ctx: Context) { - super(ctx, 'subagentControl') + constructor( + private readonly ctx: Context, + private readonly subagents: SubagentService, + ) { // Terminal publication is one of the two removal conditions. The exact // Task id pins the resolution to this activation, never a later same-child one. ctx.tasks.onTaskDone((snapshot) => { @@ -189,7 +181,7 @@ export class SubagentControlService extends Service { } }) // TaskService deliberately keeps producer Tasks alive across a - // control-surface or producer reload, so this service's disposal must not + // follow-up-tool or producer reload, so this manager's disposal must not // strand the activations it can no longer route to: cancel each one and // await producer settlement (run disposal) before releasing the map. The // effect-scoped onTaskDone listener above is already gone by then, so @@ -198,7 +190,7 @@ export class SubagentControlService extends Service { const active = [...this.activations.values()] this.activations.clear() for (const activation of active) { - activation.controller.abort('subagent control service disposed') + activation.controller.abort('subagent continuation manager disposed') activation.terminal.resolve() } await Promise.allSettled(active.map((activation) => { @@ -207,7 +199,7 @@ export class SubagentControlService extends Service { if (activation.done === undefined) return Promise.resolve() return activation.done })) - }, 'subagentControl.activations()') + }, 'subagents.continuations()') } /** @@ -239,7 +231,7 @@ export class SubagentControlService extends Service { ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, }) const taskId = this.startActivation(childId, spec.label, request.parent, signal => - this.ctx.subagents.start(spec.provider, { + this.subagents.start(spec.provider, { ...request, signal, continuation: { sessionId: childId, descriptor }, @@ -294,7 +286,7 @@ export class SubagentControlService extends Service { const activation = this.activations.get(childId) if (activation === undefined) { throw new SubagentControlError( - `subagent "${childId}" has a live agent outside control-service ownership; the message was not delivered`, + `subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`, 'OWNERSHIP_CONFLICT', ) } @@ -399,7 +391,7 @@ export class SubagentControlService extends Service { 'NOT_RESUMABLE', ) } - return this.ctx.subagents.resume(descriptor.provider, { + return this.subagents.resume(descriptor.provider, { sessionId: childId, prompt: message, source, @@ -501,4 +493,4 @@ function resumeLabel(message: ContentBlock[]): string { return text.length > 80 ? `${text.slice(0, 79)}…` : text } -export default SubagentControlService +export default SubagentContinuationManager diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 00942ca448..27b404fed9 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -3,7 +3,7 @@ * `subagent/descriptor` session event that records a child's declared * composition so a known child id can be cold-resumed after its run — and its * process — are gone. Providers append it turn-enclosed in the child's initial - * turn; the control service folds it back on resume. + * turn; the continuation manager folds it back on resume. * * The descriptor deliberately snapshots explicit fields rather than the * merge-extensible `AgentOptions` object: an unrelated extension value cannot diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 4f9a013084..f81da156eb 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -13,13 +13,11 @@ * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. * - * Scope: the seam stays collection-, Task-, and persistence-agnostic — a run - * is started or resumed and its `result` awaited, whether the consumer blocks - * on it (foreground) or registers it as a `ctx.tasks` background task (the - * generic runtime owns ids/polling/stop; this seam gains nothing task-shaped). - * Durable continuable-child ids, descriptor lookup, and Task association - * belong to `@deepseek-ai/dsh-subagent-control`; this service only validates - * and dispatches `start`/`resume` and observes run lifecycle. + * Raw `start` and `resume` remain collection-agnostic provider dispatch. + * When `ctx.tasks` and `ctx.agents` are available, the same service also binds + * an internal continuation manager for durable child ids, descriptor lookup, + * Task-backed activations, and steer-or-resume delivery. Persistence remains + * optional and is required only when a continuation operation is called. * * Same-process providers are trusted typed collaborators. Requests, provider * descriptors, results, and lifecycle payloads are borrowed immutable values; @@ -35,7 +33,7 @@ import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type { @@ -47,6 +45,12 @@ import type { SubagentStartRequest, } from './types.ts' import { SubagentRunId } from './types.ts' +import SubagentContinuationManager from './continuation.ts' +import type { + ContinuableStart, + ContinuableStartSpec, + SendMessageResult, +} from './continuation.ts' export * from './out-of-process.ts' export { SubagentRunId } from './types.ts' @@ -67,6 +71,17 @@ export { SUBAGENT_DESCRIPTOR_VERSION, } from './descriptor.ts' export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts' +export { + runOutcome, + settleRun, + SubagentControlError, +} from './continuation.ts' +export type { + ContinuableStart, + ContinuableStartSpec, + CoordinatorMessageSource, + SendMessageResult, +} from './continuation.ts' declare module '@deepseek-ai/dsh-agent' { interface AgentOptions { @@ -187,12 +202,49 @@ export class SubagentError extends HarnessError { } } -/** Named provider registry and capability-checked start surface. */ +/** Named provider registry with raw and Task-backed continuation operations. */ export class SubagentService extends Service { private providers = new Map() + private continuations: SubagentContinuationManager | undefined constructor(ctx: Context) { super(ctx, 'subagents') + ctx.inject(['tasks', 'agents'], (childCtx: Context) => { + const manager = new SubagentContinuationManager(childCtx, this) + this.continuations = manager + childCtx.effect(() => () => { + /* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */ + if (this.continuations === manager) this.continuations = undefined + }, 'subagents.continuationBinding()') + }) + } + + /** + * Start one durable continuable child through a Task-backed initial + * activation. + * @param spec - provider, Task label, and delegation request. + * @returns the stable child id and initial activation Task id. + */ + startContinuable(spec: ContinuableStartSpec): ContinuableStart { + return this.requireContinuations().startContinuable(spec) + } + + /** + * Deliver a message to a continuable child by steering its live activation + * or cold-resuming a fresh Task-backed activation. + * @param parent - live direct parent authorizing the operation. + * @param childId - durable child session id. + * @param message - user-role content to deliver. + * @param source - durable caller attribution. + * @returns the existing steered Task or newly started Task. + */ + sendMessage( + parent: Agent, + childId: SessionId, + message: ContentBlock[], + source: MessageSource, + ): Promise { + return this.requireContinuations().sendMessage(parent, childId, message, source) } /** @@ -264,7 +316,7 @@ export class SubagentService extends Service { /** * Resume a persisted continuable child through the named provider's * `resume` capability, with the same run lifecycle observation as - * {@link start}. The caller (the control service) has already loaded the + * {@link start}. The internal continuation manager has already loaded the * child, folded its descriptor, and authorized the parent; this method owns * only capability-checked dispatch. * @param name - the provider recorded in the child's descriptor. @@ -291,6 +343,17 @@ export class SubagentService extends Service { return provider } + /** Resolve the optional Task-backed continuation runtime or fail loud. */ + private requireContinuations(): SubagentContinuationManager { + if (this.continuations === undefined) { + throw new SubagentError( + 'continuable subagents require the tasks and agents services', + 'CONTINUATION_UNAVAILABLE', + ) + } + return this.continuations + } + /** Emit the start/end lifecycle pair for one accepted run and return it. */ private observeRun(name: string, parent: Agent, run: SubagentRun): SubagentRun { const runId = SubagentRunId(randomUUID()) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index aa215be03c..50922b183e 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -94,7 +94,7 @@ export interface SubagentStartRequest { */ readonly persona?: string /** - * Continuable-child intent, resolved by the control service before start. + * Continuable-child intent, resolved by `ctx.subagents` before start. * The provider MUST publish exactly `sessionId` as the child identity * instead of allocating one internally, and MUST append the snapshotted * `descriptor` as the child's turn-enclosed `subagent/descriptor` event @@ -106,10 +106,10 @@ export interface SubagentStartRequest { /** * The resolved continuable-child identity and durable composition record a - * control-service caller attaches to a start request. + * continuation caller attaches to a start request. */ export interface SubagentContinuation { - /** Control-allocated stable child session id, published verbatim. */ + /** Service-allocated stable child session id, published verbatim. */ readonly sessionId: SessionId /** Snapshotted descriptor persisted in the child log for cold resume. */ readonly descriptor: SubagentDescriptorData @@ -117,7 +117,7 @@ export interface SubagentContinuation { /** * What a caller asks for when resuming a persisted continuable child. The - * control service loads the child log, folds and authorizes its descriptor, + * continuation manager loads the child log, folds and authorizes its descriptor, * and passes this fully resolved request to * {@link SubagentService.resume}, which dispatches to * {@link SubagentProvider.resume}. The provider reconstructs the declared diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts similarity index 90% rename from packages/subagent/subagent-control/tests/subagent-control.spec.ts rename to packages/subagent/subagent/tests/continuation.spec.ts index 9e9e4573e8..5a0d20c481 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import { TaskId } from '@deepseek-ai/dsh-tasks' @@ -18,7 +17,12 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts' +import SubagentService, { + runOutcome, + settleRun, + SubagentControlError, + SUBAGENT_DESCRIPTOR_VERSION, +} from '../src/index.ts' type Script = ConstructorParameters[0] @@ -53,12 +57,12 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -/** Boot the full continuable stack: loop, persistence, providers, tasks, control. */ +/** Boot the full continuable stack: loop, persistence, providers, tasks, and subagents. */ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) if (options.persistence !== false) { - const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-control-')) + const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) } @@ -68,7 +72,6 @@ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } await ctx.plugin(SubagentFork, { providerName: 'fork' }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) - await ctx.plugin(SubagentControlService) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } @@ -93,12 +96,12 @@ async function waitTerminal(ctx: Context, taskId: TaskId, parent: Agent) { } async function waitPublishedRun(ctx: Context, childId: SessionId): Promise { - const control = ctx.subagentControl as unknown as { - activations: Map + const continuations = ctx.subagents as unknown as { + continuations: { activations: Map } } await new Promise((resolve) => { const timer = setInterval(() => { - if (control.activations.get(childId)?.run !== undefined) { + if (continuations.continuations.activations.get(childId)?.run !== undefined) { clearInterval(timer) resolve() } @@ -121,13 +124,13 @@ function sendMessage( childId: SessionId, content: ReturnType, ) { - return ctx.subagentControl.sendMessage(parent, childId, content, { kind: 'user' }) + return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' }) } -describe('SubagentControlService.startContinuable', () => { +describe('SubagentService.startContinuable', () => { it('returns both identities immediately; the Task settles with the child result after disposal', async () => { const { ctx, parent } = await setup([textResponse('first answer')]) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) expect(started.childId).toMatch(/[0-9a-f-]{36}/) expect(started.taskId).toBe('subagent-1') @@ -138,13 +141,13 @@ describe('SubagentControlService.startContinuable', () => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) - it('publishes the control-allocated child id and appends the turn-enclosed descriptor', async () => { + it('publishes the service-allocated child id and appends the turn-enclosed descriptor', async () => { const { ctx, parent } = await setup([textResponse('answer')]) const seen: SessionEvent[] = [] ctx.on('session/event', (session, event) => { if (session.id !== SessionId('parent')) seen.push(event) }) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) const descriptorIndex = seen.findIndex(event => event.type === 'subagent/descriptor') @@ -162,7 +165,7 @@ describe('SubagentControlService.startContinuable', () => { // Model-hidden: the descriptor never carries surface metadata. expect('surfaceOp' in descriptor).toBe(false) - // The durable log kept the exact control-allocated id. + // The durable log kept the exact service-allocated id. const loaded = await ctx.sessionPersistence.load(started.childId) expect(loaded.meta.id).toBe(started.childId) expect(loaded.meta.parentSession).toBe(SessionId('parent')) @@ -171,7 +174,7 @@ describe('SubagentControlService.startContinuable', () => { it('rejects synchronously with no Task when persistence is not configured', async () => { const { ctx, parent } = await setup([textResponse('unused')], { persistence: false }) - expect(() => ctx.subagentControl.startContinuable(startSpec(parent))) + expect(() => ctx.subagents.startContinuable(startSpec(parent))) .toThrow(/require session persistence/) expect(ctx.tasks.list(parent)).toEqual([]) }) @@ -181,19 +184,21 @@ describe('SubagentControlService.startContinuable', () => { const realStart = ctx.tasks.start.bind(ctx.tasks) ctx.tasks.start = () => { throw new Error('task preflight failed') } try { - expect(() => ctx.subagentControl.startContinuable(startSpec(parent))) + expect(() => ctx.subagents.startContinuable(startSpec(parent))) .toThrow('task preflight failed') } finally { ctx.tasks.start = realStart } - const control = ctx.subagentControl as unknown as { activations: Map } - expect(control.activations.size).toBe(0) + const continuations = ctx.subagents as unknown as { + continuations: { activations: Map } + } + expect(continuations.continuations.activations.size).toBe(0) }) it('rejects a non-JSON descriptor input synchronously with no Task', async () => { const { ctx, parent } = await setup([textResponse('unused')]) const spec = startSpec(parent) - expect(() => ctx.subagentControl.startContinuable({ + expect(() => ctx.subagents.startContinuable({ ...spec, // A symbol survives the static ToolRestriction type only through this // cast — exactly the durable-boundary input the snapshot rejects. @@ -214,7 +219,7 @@ describe('SubagentControlService.startContinuable', () => { maxDepth: 0, }, } - const started = ctx.subagentControl.startContinuable(spec) + const started = ctx.subagents.startContinuable(spec) const snapshot = await waitTerminal(ctx, started.taskId, parent) expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain('maxDepth') @@ -228,7 +233,7 @@ describe('SubagentControlService.startContinuable', () => { it('task_kill during the run aborts, disposes, and settles killed after quiescence', async () => { const { ctx, parent } = await setup(['hang']) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) // Let the child publish and begin its turn. await new Promise(resolve => setTimeout(resolve, 30)) expect(ctx.agents.get(started.childId)).toBeDefined() @@ -250,7 +255,7 @@ describe('SubagentControlService.startContinuable', () => { checkpointStarted.resolve(undefined) await releaseCheckpoint.promise }) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await checkpointStarted.promise expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested') @@ -262,7 +267,7 @@ describe('SubagentControlService.startContinuable', () => { }) }) -describe('SubagentControlService.sendMessage', () => { +describe('SubagentService.sendMessage', () => { it('omits undeclared model selectors and rejects a provider without live delivery', async () => { const { ctx } = await setup([]) const result = Promise.withResolvers<{ @@ -286,7 +291,7 @@ describe('SubagentControlService.sendMessage', () => { resume: async () => { throw new Error('not used') }, }) const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}) - const started = ctx.subagentControl.startContinuable(startSpec(parent, 'no-steer')) + const started = ctx.subagents.startContinuable(startSpec(parent, 'no-steer')) await waitPublishedRun(ctx, started.childId) expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) @@ -336,7 +341,7 @@ describe('SubagentControlService.sendMessage', () => { }, resume: async () => { throw new Error('not used') }, }) - const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local')) + const started = ctx.subagents.startContinuable(startSpec(parent, 'mismatched-local')) await waitPublishedRun(ctx, started.childId) await expect(sendMessage(ctx, parent, started.childId, message('join'))) @@ -357,7 +362,7 @@ describe('SubagentControlService.sendMessage', () => { ]) const { ctx, parent } = await setupWith(adapter) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) // Wait until the first immutable request has crossed the adapter boundary. await new Promise((resolve) => { const timer = setInterval(() => { @@ -368,7 +373,7 @@ describe('SubagentControlService.sendMessage', () => { }, 5) }) - const delivery = ctx.subagentControl.sendMessage( + const delivery = ctx.subagents.sendMessage( parent, started.childId, message('also consider Y'), @@ -406,7 +411,7 @@ describe('SubagentControlService.sendMessage', () => { }) const base = startSpec(parent) - const started = ctx.subagentControl.startContinuable({ + const started = ctx.subagents.startContinuable({ ...base, request: { ...base.request, @@ -419,7 +424,7 @@ describe('SubagentControlService.sendMessage', () => { }) await startedTool.promise - const delivery = ctx.subagentControl.sendMessage( + const delivery = ctx.subagents.sendMessage( parent, started.childId, message('follow-up that terminal policy rejects'), @@ -437,11 +442,11 @@ describe('SubagentControlService.sendMessage', () => { it('cold-resumes a settled child into a fresh Task and reports `started`', async () => { const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) expect(ctx.agents.get(started.childId)).toBeUndefined() - const followUp = await ctx.subagentControl.sendMessage( + const followUp = await ctx.subagents.sendMessage( parent, started.childId, message('and then?'), @@ -476,7 +481,7 @@ describe('SubagentControlService.sendMessage', () => { toolFilter: { deny: [] as string[] }, }, } - const started = ctx.subagentControl.startContinuable(spec) + const started = ctx.subagents.startContinuable(spec) await waitTerminal(ctx, started.taskId, parent) const loaded = await ctx.sessionPersistence.load(started.childId) @@ -503,7 +508,7 @@ describe('SubagentControlService.sendMessage', () => { parent.followup(createUserMessage({ content: message('parent question one'), source: { kind: 'user' } })) await parent.whenIdle() - const started = ctx.subagentControl.startContinuable(startSpec(parent, 'fork')) + const started = ctx.subagents.startContinuable(startSpec(parent, 'fork')) await waitTerminal(ctx, started.taskId, parent) const firstLoad = await ctx.sessionPersistence.load(started.childId) const seedLength = firstLoad.meta.seedLength ?? 0 @@ -527,7 +532,7 @@ describe('SubagentControlService.sendMessage', () => { it('a resumed child cannot regain a top-level delegation budget (header floor)', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) const followUp = await sendMessage(ctx, parent, started.childId, message('go on')) @@ -546,7 +551,7 @@ describe('SubagentControlService.sendMessage', () => { it('rejects a foreign child id: the started Task fails with UNAUTHORIZED and delivers nothing', async () => { const { ctx, parent } = await setup([textResponse('other parent answer'), textResponse('unused')]) const otherParent = ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' }) - const started = ctx.subagentControl.startContinuable(startSpec(otherParent)) + const started = ctx.subagents.startContinuable(startSpec(otherParent)) await waitTerminal(ctx, started.taskId, otherParent) const attempt = await sendMessage(ctx, parent, started.childId, message('mine now')) @@ -590,9 +595,9 @@ describe('SubagentControlService.sendMessage', () => { ]) }) - it('rejects delivery to a live agent outside control-service ownership', async () => { + it('rejects delivery to a live agent outside continuation ownership', async () => { const { ctx, parent } = await setup([textResponse('unused')]) - // A live child created around the control service. + // A live child created outside continuation orchestration. const handle = await ctx.agents.create({ sessionId: SessionId('rogue-child'), meta: { parentSession: parent.id }, @@ -601,7 +606,7 @@ describe('SubagentControlService.sendMessage', () => { await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) .rejects.toThrow(SubagentControlError) await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) - .rejects.toThrow(/outside control-service ownership.*not delivered/) + .rejects.toThrow(/outside continuation ownership.*not delivered/) await handle.dispose() }) @@ -625,7 +630,7 @@ describe('SubagentControlService.sendMessage', () => { } } - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) // Wait for the child to finish its turn while the run remains undisposed // and the association therefore still holds. await new Promise((resolve) => { @@ -654,7 +659,7 @@ describe('SubagentControlService.sendMessage', () => { it('each follow-up Task result is fenced to the parent session', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) const followUp = await sendMessage(ctx, parent, started.childId, message('more')) const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' }) @@ -663,7 +668,7 @@ describe('SubagentControlService.sendMessage', () => { it('kills a cold-resume activation during descriptor lookup without starting child work', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('never used')]) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) // Make the persistence load hang until the kill lands. @@ -686,7 +691,7 @@ describe('SubagentControlService.sendMessage', () => { it('admits one process-local activation per child: a second send during resume load steers or fails, never duplicates', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed answer')]) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) @@ -715,15 +720,15 @@ describe('service disposal with live activations', () => { it('cancels and settles a starting activation on service disposal instead of stranding it', async () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-control-hmr-')) + const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-hmr-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SubagentService) + const subagentsFiber = await ctx.plugin(SubagentService) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) // A provider that stays pending until its signal aborts, so the activation - // is observably mid-start when the control service is disposed. + // is observably mid-start when the subagent service is disposed. let sawAbort = false ctx.subagents.registerProvider({ name: 'pending', @@ -737,19 +742,17 @@ describe('service disposal with live activations', () => { }), resume: () => Promise.reject(new Error('unreachable')), }) - const controlFiber = await ctx.plugin(SubagentControlService) ctx.llm.registerAdapter(['mock'], new MockAdapter([])) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - const control = ctx.get('subagentControl')! - const started = control.startContinuable({ + const started = ctx.subagents.startContinuable({ provider: 'pending', label: 'will be interrupted', request: { prompt: message('go'), parent }, }) - // LocalTaskService keeps the producer Task; the disposing control service must + // LocalTaskService keeps the producer Task; the disposing subagent service must // cancel its activation and await settlement rather than strand it. - await controlFiber.dispose() + await subagentsFiber.dispose() expect(sawAbort).toBe(true) const snapshot = await waitTerminal(ctx, started.taskId, parent) expect(snapshot.status).toBe('killed') diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 69dacf70f6..90128302c5 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -125,6 +125,17 @@ describe('SubagentService', () => { })).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) }) + it('rejects Task-backed continuation operations when their runtime services are absent', async () => { + const { subagents } = await service() + expect(() => { + subagents.startContinuable({ + provider: 'unused', + label: 'work', + request: baseRequest(), + }) + }).toThrow(expect.objectContaining({ code: 'CONTINUATION_UNAVAILABLE' })) + }) + it.each([ ['outputSchema', { outputSchema: { type: 'object', properties: {} } }], ['depthLimit', { maxDepth: 1 }], diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 713e214f04..6684758659 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -26,6 +26,12 @@ { "path": "../../core/scope" }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../tasks/tasks" + }, { "path": "../../support/invariants" } diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 6a6026e3fd..c308e11d99 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-tool-subagent-control -The globally named `send_message` tool: a thin adapter over `ctx.subagentControl.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers the one shared control tool, so multiple delegation tools never register duplicate global controls. +The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work. -The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the control service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered. +The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the subagent service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered. ## Model Experience diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 96c91c4ec6..4fdd483ff7 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", - "description": "Globally named send_message tool over the continuable-subagent control service", + "description": "Globally named send_message tool over ctx.subagents continuations", "version": "0.0.1", "private": true, "type": "module", @@ -30,7 +30,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent-control": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -44,7 +44,6 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-control": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 959ff8eb49..d95ecb77a9 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -1,7 +1,7 @@ /** * The globally named `send_message` tool: a thin model-facing adapter over - * `ctx.subagentControl.sendMessage()`. It performs no lifecycle routing of its - * own — steer-or-resume orchestration belongs to the control service — and it + * `ctx.subagents.sendMessage()`. It performs no lifecycle routing of its + * own — steer-or-resume orchestration belongs to the subagent service — and it * lives apart from the provider-bound `@deepseek-ai/dsh-tool-subagent` * instances so multiple delegation tools share one control tool. * @module @deepseek-ai/dsh-tool-subagent-control @@ -11,14 +11,14 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-subagent-control' +import type {} from '@deepseek-ai/dsh-subagent' export const name = 'tool-subagent-control' -export const inject = ['tools', 'subagentControl'] +export const inject = ['tools', 'subagents'] /** * Register the `send_message` tool. - * @param ctx - context carrying the tool registry and the control service. + * @param ctx - context carrying the tool registry and subagent service. */ export function apply(ctx: Context): void { ctx.tools.register(defineTool({ @@ -67,7 +67,7 @@ export function apply(ctx: Context): void { throw new Error('send_message requires a calling agent (exec.agent was undefined)') } const message: ContentBlock[] = [{ type: 'text', text: args.message }] - const result = ctx.subagentControl.sendMessage( + const result = ctx.subagents.sendMessage( parent, SessionId(args.subagent_id), message, diff --git a/packages/subagent/tool-subagent-control/src/invariant.ts b/packages/subagent/tool-subagent-control/src/invariant.ts index 6fb1c19ea6..c993426a26 100644 --- a/packages/subagent/tool-subagent-control/src/invariant.ts +++ b/packages/subagent/tool-subagent-control/src/invariant.ts @@ -16,7 +16,7 @@ export const inject = ['invariants'] /** * No runtime invariant: this model-facing adapter has no independent lifecycle stream; delivery - * and activation relations are owned by the control service it calls. + * and activation relations are owned by the subagent service it calls. */ const install: InvariantInstaller = () => {} diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 93c401007e..02c7e6c1a3 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' -import SubagentControlService from '@deepseek-ai/dsh-subagent-control' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' @@ -34,7 +33,6 @@ async function setup(script: ConstructorParameters[0]) { await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) - await ctx.plugin(SubagentControlService) await ctx.plugin(tool) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) @@ -68,7 +66,7 @@ describe('dsh-tool-subagent-control', () => { it('cold-resumes a settled child and renders the started route with its task id', async () => { const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) - const started = ctx.subagentControl.startContinuable({ + const started = ctx.subagents.startContinuable({ provider: 'spawn', label: 'work', request: { prompt: [{ type: 'text', text: 'child task' }], parent }, @@ -98,10 +96,10 @@ describe('dsh-tool-subagent-control', () => { const { ctx, parent } = await setup([]) let steered: string | undefined let source: unknown - // Reach past the tool into the control service to fake a running route + // Reach past the tool into the subagent service to fake a running route // deterministically: the tool is a thin adapter, so its steered wording is // what this test pins. - ctx.subagentControl.sendMessage = async (agent, _childId, message, messageSource) => { + ctx.subagents.sendMessage = async (agent, _childId, message, messageSource) => { steered = (message[0] as { text: string }).text source = messageSource return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } @@ -116,7 +114,7 @@ describe('dsh-tool-subagent-control', () => { expect(text(result)).toBe('message delivered to running task subagent-9') }) - it('reports a control-service failure as an errored, not-delivered result', async () => { + it('reports a delivery failure as an errored, not-delivered result', async () => { const { ctx, parent } = await setup([]) const result = await callTool(ctx, 'send_message', { subagent_id: 'no-such-child', @@ -148,7 +146,6 @@ describe('dsh-tool-subagent-control', () => { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(LocalTaskService) - await ctx.plugin(SubagentControlService) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true) await fiber.dispose() @@ -158,7 +155,7 @@ describe('dsh-tool-subagent-control', () => { it('has the namespace-plugin export shape (no stray default)', () => { expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent-control') - expect(tool.inject).toEqual(['tools', 'subagentControl']) + expect(tool.inject).toEqual(['tools', 'subagents']) expect(typeof tool.apply).toBe('function') }) }) diff --git a/packages/subagent/tool-subagent-control/tsconfig.json b/packages/subagent/tool-subagent-control/tsconfig.json index 4b2ec045e6..3a57a0437e 100644 --- a/packages/subagent/tool-subagent-control/tsconfig.json +++ b/packages/subagent/tool-subagent-control/tsconfig.json @@ -24,7 +24,7 @@ "path": "../../core/tools" }, { - "path": "../subagent-control" + "path": "../subagent" }, { "path": "../../support/invariants" diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index c4660b5517..46daae94ea 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/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/subagent/tool-subagent/README.md -README.md: 7d32da3c974361eb5e58cdb2ee5be756383ad3d1 -README.zh.md: eadc168fd07701b3e3d9600b3fe69bd8b22e235a +README.md: 9d60363602a9825730984700a7fe987d911e1cac +README.zh.md: 5964c38bd847c1c14cac9decdd913ca65c39e8f3 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 7d32da3c97..9d60363602 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -10,7 +10,7 @@ Each plugin instance binds one `provider` to one `toolName`; the model receives A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output. -With `run_in_background: true`, the route follows the provider's continuation capability and returns canonical `{ kind: 'background', taskId, subagentId? }`. A resumable provider (spawn, fork) delegates to `ctx.subagentControl.startContinuable()`, which owns the durable child id, descriptor snapshot, Task registration, and settle-then-dispose ordering; the result includes `subagentId`, renders as `started subagent as task `, and accepts follow-up messages through the global `send_message` tool. A one-shot provider (ACP) keeps the plain parent-owned task, omits `subagentId`, and renders as `started background subagent task `. Either way a task-owned signal covers pending startup and the child after the starting call returns; `task_kill` and owner disposal abort it, settlement awaits startup rollback or child disposal, and completed final text, abort to `killed`, and other failures to `failed` map identically. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md) and the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md). +With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports resume. `continuable` requires `provider.resume`, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'background', taskId, subagentId }`, rendered as `started subagent as task `. The optional global `send_message` tool is not required to start continuable work. Either route uses a Task-owned signal, settles only after startup rollback or run disposal, and maps completed final text, abort → `killed`, and other failures → `failed`. Generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). `toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). @@ -21,6 +21,7 @@ With `run_in_background: true`, the route follows the provider's continuation ca | `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). | | `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. | | `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. | +| `backgroundMode` | Background lifecycle policy, default `one-shot`. `continuable` requires provider resume support and returns a durable child id; it does not require the follow-up tool. | | `agentOptions` | Provider-specific child `provider`, `model`, and positive `maxTokens`; the in-process provider treats explicit values as overrides of inherited parent options. | | `persona` | Per-child persona; requires provider `persona` capability. | | `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. | @@ -64,7 +65,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Start returns exactly `started subagent as task ` on a resumable provider, or `started background subagent task ` on a one-shot provider. The generic task surface provides later status, final output, cancellation responses, and notices; `send_message` (from `dsh-tool-subagent-control`) delivers follow-ups to a continuable child. +Start returns exactly `started subagent as task ` in configured continuable mode, or `started background subagent task ` in configured one-shot mode. The generic task surface provides later status, final output, cancellation responses, and notices; an independently loaded `send_message` tool delivers follow-ups to a continuable child. #### Token effect diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index eadc168fd0..5964c38bd8 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -10,7 +10,7 @@ 前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。 -设置 `run_in_background: true` 后,路由遵循提供方的继续功能,并返回规范值 `{ kind: 'background', taskId, subagentId? }`。可恢复提供方(spawn、fork)会委派给 `ctx.subagentControl.startContinuable()`,由它拥有持久化子 agent ID、描述符快照、Task 注册和先结算后 dispose(资源释放)的顺序;结果包含 `subagentId`,渲染为 `started subagent as task `,并通过全局 `send_message` 工具接收后续消息。一次性提供方 ACP(Agent Client Protocol)保留普通的父级所有任务,省略 `subagentId`,并渲染为 `started background subagent task `。两条路径中,任务拥有的信号都会覆盖待处理的启动阶段和启动调用返回后的子 agent;`task_kill` 和所有者 dispose 会中止它,结算会等待启动回滚或子 agent dispose,然后把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。任务不提供增量读取;通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)和[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)。 +设置 `run_in_background: true` 后,由 `backgroundMode` 选择路由。`one-shot` 会注册普通的父级所有 Task,并返回规范值 `{ kind: 'background', taskId }`;即使提供方支持恢复,也会渲染为 `started background subagent task `。`continuable` 要求 `provider.resume`,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'background', taskId, subagentId }`,渲染为 `started subagent as task `。启动可继续工作不要求加载可选的全局 `send_message` 工具。两条路由都使用 Task 所有的信号,只在启动回滚或 run dispose(资源释放)之后结算,并把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -21,6 +21,7 @@ | `provider`(必填) | 提供方名称(`spawn`、`fork`、`acp` 等)。 | | `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 | | `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 | +| `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`continuable` 要求提供方支持恢复并返回持久化子 agent ID;它不要求加载后续消息工具。 | | `agentOptions` | 传给具体提供方的子 agent `provider`、`model` 和正整数 `maxTokens`;进程内提供方会用显式值覆盖继承的父级选项。 | | `persona` | 每个子 agent 独立的 persona;要求提供方具备 `persona` 能力。 | | `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 | @@ -64,7 +65,7 @@ #### 模型看到的内容 -对于可恢复提供方,启动时精确返回 `started subagent as task `;对于一次性提供方,则返回 `started background subagent task `。通用任务接口提供后续状态、最终输出、取消响应和通知;`send_message`(来自 `dsh-tool-subagent-control`)会把后续消息交付给可继续子 agent。 +在已配置的 continuable 模式下,启动时精确返回 `started subagent as task `;在已配置的 one-shot 模式下,则返回 `started background subagent task `。通用任务接口提供后续状态、最终输出、取消响应和通知;独立加载的 `send_message` 工具会把后续消息交付给可继续子 agent。 #### Token 影响 diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index d789c9b4f9..353f67ff24 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -31,7 +31,6 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subagent-control": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -48,7 +47,6 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-control": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 560e0cb20b..6033bea875 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -1,11 +1,10 @@ /** * Model-facing delegation through one configured `ctx.subagents` provider. * Provider lifecycle controls tool registration and context-sensitive schema - * wording. Foreground calls always dispose the run after collection. A - * background call's route follows the provider's continuation capability: - * a provider with `resume` delegates to `ctx.subagentControl`, which owns the - * durable child id, its descriptor, and the Task-backed activation lifecycle; - * a provider without it (ACP) keeps the one-shot background task. + * wording. Foreground calls always dispose the run after collection. + * Background policy is selected by this plugin's configuration: one-shot + * calls own a plain Task, while continuable calls use + * `ctx.subagents.startContinuable()`. * @module @deepseek-ai/dsh-tool-subagent */ @@ -15,9 +14,8 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-session' -import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' +import { assertSubagentMaxDepth, settleRun } from '@deepseek-ai/dsh-subagent' import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent' -import { settleRun } from '@deepseek-ai/dsh-subagent-control' import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' export const name = 'tool-subagent' @@ -37,6 +35,12 @@ export interface Config { * parameter and reject forced background calls. */ enableRunInBackground?: boolean + /** + * Background execution policy (default `one-shot`). `continuable` requires + * a provider with persisted resume support and returns both child and Task + * ids; follow-up adapters remain independently optional. + */ + backgroundMode?: 'one-shot' | 'continuable' /** * Agent options applied to every child; omitted fields use child-loop defaults. */ @@ -73,6 +77,7 @@ export const Config: z = z.object({ provider: z.string().required(), toolName: z.string().default('subagent'), enableRunInBackground: z.boolean().default(true), + backgroundMode: z.union(['one-shot', 'continuable'] as const).default('one-shot'), // Prevent Schemastery from materializing omitted agentOptions as `{}`. agentOptions: z.object({ provider: z.string(), @@ -191,17 +196,19 @@ export function apply(ctx: Context, config: Config): void { } const wording = providerWording(provider.inheritsParentContext) const backgroundEnabled = config.enableRunInBackground !== false - // The provider's continuation capability decides the background route: a - // resumable provider starts durable, follow-up-able children through the - // control service, while a one-shot provider (ACP) keeps the plain task. - const continuable = provider.resume !== undefined + const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable' + if (continuable && provider.resume === undefined) { + throw new Error( + `tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``, + ) + } disposeTool = ctx.tools.register(defineTool({ name: config.toolName ?? 'subagent', description: wording.description + (backgroundEnabled ? continuable ? ' Set `run_in_background: true` to start a continuable background subagent: you receive its' - + ' subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`,' - + ' and send follow-up messages with `send_message`.' + + ' stable subagent id and current task id; collect the result with `task_output` and stop it with' + + ' `task_kill`.' : ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' : ''), parameters: { @@ -220,7 +227,7 @@ export function apply(ctx: Context, config: Config): void { type: 'boolean' as const, description: continuable ? 'Run as a continuable background subagent and return its subagent and task ids; ' - + 'collect with task_output, stop with task_kill, follow up with send_message.' + + 'collect with task_output or stop with task_kill.' : 'Run as a background task and return its id; collect with task_output or stop with task_kill.', }, } : {}, @@ -281,23 +288,7 @@ export function apply(ctx: Context, config: Config): void { throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)') } if (continuable) { - const control = ctx.get('subagentControl') - if (control === undefined) { - throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-subagent-control and @deepseek-ai/dsh-tool-tasks') - } - // The schema above tells the model to follow up with - // `send_message`; starting a durable child the model cannot - // continue would make that advertisement false. Sibling load order - // is undetermined at mount, so the check lives at the operation, - // and it resolves in the CALLER's scope so a restriction that - // removes send_message from this agent also blocks the start. - if (ctx.tools.get('send_message', parent) === undefined) { - throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-tool-subagent-control (the advertised send_message tool is not registered)') - } - // The control service owns the durable child id, descriptor - // snapshot, Task registration, and settle-then-dispose ordering; a - // synchronous validation failure rejects the call with no Task. - const started = control.startContinuable({ + const started = ctx.subagents.startContinuable({ provider: config.provider, label: args.description, request, diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index f631133970..1b45640a94 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -15,9 +15,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' -import SubagentControlService from '@deepseek-ai/dsh-subagent-control' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' -import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' @@ -70,6 +68,21 @@ function text(result: { content: { type: string; text?: string }[] }): string { } describe('dsh-tool-subagent', () => { + it('rejects continuable background policy when the configured provider cannot resume', async () => { + let failure: unknown + try { + await setup({ + provider: 'mock', + backgroundMode: 'continuable', + }) + } catch (error: unknown) { + failure = error + } + expect(String(failure)).toContain( + 'provider "mock" does not support `backgroundMode: continuable`', + ) + }) + it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => { const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' }) const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' }) @@ -655,6 +668,47 @@ describe('dsh-tool-subagent background mode', () => { return ctx } + it('keeps a resumable provider one-shot when backgroundMode selects one-shot', async () => { + const ctx = await backgroundSetup({ provider: 'mock' }) + const parent = ownerAgent(ctx, 'sess-parent') + let resumeCalls = 0 + ctx.subagents.registerProvider({ + name: 'resumable', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async request => ({ + id: SessionId('one-shot-child'), + localAgent: undefined, + result: Promise.resolve({ + output: [{ type: 'text', text: 'one-shot answer' }], + stopReason: request.signal.aborted ? 'aborted' : 'completed', + }), + dispose: () => Promise.resolve(), + }), + resume: async () => { + resumeCalls += 1 + throw new Error('one-shot policy must not resume') + }, + }) + tool.apply(ctx, { + provider: 'resumable', + toolName: 'subagent_resumable', + backgroundMode: 'one-shot', + maxDepth: 'provider-managed', + }) + + const started = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('resumable-one-shot'), + name: 'subagent_resumable', + arguments: { description: 'work', prompt: 'go', run_in_background: true }, + agent: parent, + }) + + expect(text(started)).toBe('started background subagent task subagent-1') + expect(resumeCalls).toBe(0) + }) + it('returns a task id immediately and the answer is collected through task_output', async () => { const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' }) const parent = ownerAgent(ctx, 'sess-parent') @@ -825,8 +879,8 @@ describe('dsh-tool-subagent continuable background mode', () => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) - /** Boot the real continuable stack: loop, persistence, spawn, tasks, control. */ - async function continuableSetup(options: { controlTool?: boolean } = {}) { + /** Boot the real continuable stack without any model-facing follow-up adapter. */ + async function continuableSetup() { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-')) @@ -837,9 +891,7 @@ describe('dsh-tool-subagent continuable background mode', () => { await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) - await ctx.plugin(SubagentControlService) - if (options.controlTool !== false) await ctx.plugin(ToolSubagentControl) - await ctx.plugin(tool, { provider: 'spawn' }) + await ctx.plugin(tool, { provider: 'spawn', backgroundMode: 'continuable' }) ctx.llm.registerAdapter(['mock'], new MockAdapter([ textResponse('continuable answer'), ])) @@ -847,10 +899,10 @@ describe('dsh-tool-subagent continuable background mode', () => { return { ctx, parent } } - it('a resumable provider advertises send_message and returns both ids', async () => { + it('starts a continuable child and returns both ids without send_message', async () => { const { ctx, parent } = await continuableSetup() const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! - expect(schema.description).toContain('send_message') + expect(schema.description).not.toContain('send_message') const started = await callSubagent( ctx, @@ -869,56 +921,6 @@ describe('dsh-tool-subagent continuable background mode', () => { expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) }) - it('fails loud when the provider is resumable but the control service is not loaded', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(SubagentService) - // A resumable provider without ctx.subagentControl. - ctx.subagents.registerProvider({ - name: 'resumable', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start: () => { throw new Error('unreachable') }, - resume: () => { throw new Error('unreachable') }, - }) - await ctx.plugin(tool, { provider: 'resumable', maxDepth: 'provider-managed' }) - - const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }) - expect(result.isError).toBe(true) - expect(text(result)).toContain('load @deepseek-ai/dsh-subagent-control') - }) - - it('fails loud when the advertised send_message tool is not registered', async () => { - // The schema tells the model to follow up with send_message; starting a - // durable child the model cannot continue would make that false. - const { ctx, parent } = await continuableSetup({ controlTool: false }) - const result = await callSubagent( - ctx, - { description: 'd', prompt: 'p', run_in_background: true }, - { agent: parent }, - ) - expect(result.isError).toBe(true) - expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control') - // Nothing was started: no Task exists for the parent. - expect(ctx.tasks.list(parent)).toEqual([]) - }) - - it('resolves send_message availability in the CALLER scope, not the global registry', async () => { - // A scoped restriction that keeps this delegation tool but removes - // send_message means this agent cannot execute the promised follow-up; - // the availability check must see the caller's surface. - const { ctx, parent } = await continuableSetup() - parent.ctx.tools.restrict({ deny: ['send_message'] }) - const result = await callSubagent( - ctx, - { description: 'd', prompt: 'p', run_in_background: true }, - { agent: parent }, - ) - expect(result.isError).toBe(true) - expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control') - expect(ctx.tasks.list(parent)).toEqual([]) - }) }) describe('background preflight failure (no orphaned child, by construction)', () => { diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json index a542b520b1..25780c367f 100644 --- a/packages/subagent/tool-subagent/tsconfig.json +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../subagent" }, - { - "path": "../subagent-control" - }, { "path": "../../tasks/tasks" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80d30aeae9..45790d4591 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -384,9 +384,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent - '@deepseek-ai/dsh-subagent-control': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-control '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../packages/subagent/subagent-fork @@ -737,9 +734,6 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp - '@deepseek-ai/dsh-subagent-control': - specifier: workspace:* - version: link:../packages/subagent/subagent-control '@deepseek-ai/dsh-subagent-dsh-sdk': specifier: workspace:* version: link:../packages/subagent/subagent-dsh-sdk @@ -4885,6 +4879,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-tasks': + specifier: workspace:^ + version: link:../../tasks/tasks '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -4932,51 +4932,6 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - packages/subagent/subagent-control: - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-loop-testkit': - specifier: workspace:^ - version: link:../../support/agent-loop-testkit - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session-persistence/session-persistence - '@deepseek-ai/dsh-subagent': - specifier: workspace:^ - version: link:../subagent - '@deepseek-ai/dsh-subagent-fork': - specifier: workspace:^ - version: link:../subagent-fork - '@deepseek-ai/dsh-subagent-spawn': - specifier: workspace:^ - version: link:../subagent-spawn - '@deepseek-ai/dsh-tasks': - specifier: workspace:^ - version: link:../../tasks/tasks - '@deepseek-ai/dsh-tasks-local': - specifier: workspace:^ - version: link:../../tasks/tasks-local - '@deepseek-ai/dsh-tool-tasks': - specifier: workspace:^ - version: link:../../tasks/tool-tasks - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - packages/subagent/subagent-dsh-sdk: dependencies: schemastery: @@ -5184,9 +5139,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent - '@deepseek-ai/dsh-subagent-control': - specifier: workspace:^ - version: link:../subagent-control '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../subagent-spawn @@ -5238,9 +5190,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent - '@deepseek-ai/dsh-subagent-control': - specifier: workspace:^ - version: link:../subagent-control '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../subagent-spawn @@ -6510,9 +6459,6 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:^ version: link:../../packages/subagent/subagent-acp - '@deepseek-ai/dsh-subagent-control': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-control '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../packages/subagent/subagent-fork diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index df5d302915..a4d555055d 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -66,7 +66,6 @@ "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-acp": "workspace:^", - "@deepseek-ai/dsh-subagent-control": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 02215862db..ed46fd687d 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -424,19 +424,11 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'subagents', pkg: 'subagent', - title: 'Subagent provider registry', + title: 'Subagent provider and continuation service', mode: 'seam', implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'], - consumers: ['tool-subagent', 'tool-ralph'], - note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.', - }, - { - key: 'subagentControl', - pkg: 'subagent', - title: 'Continuable-subagent control service', - mode: 'core', - consumers: ['tool-subagent', 'tool-subagent-control'], - note: 'Binds one durable child session to Task-backed activations over ctx.subagents; tool-subagent starts continuable background children and tool-subagent-control delivers follow-up messages.', + consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], + note: 'Providers implement transports; the service also owns optional Task-backed continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, { key: 'tasks', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 9d3907821a..96d6a97457 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -28,7 +28,6 @@ import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' -import SubagentControlService from '@deepseek-ai/dsh-subagent-control' import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' @@ -108,8 +107,6 @@ function registerCatalogSubagentProvider(ctx: Context, name: string): void { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')), - // Presence marks the continuation capability, so tool-subagent harvests - // its shipped continuable background wording (spawn/fork are resumable). resume: () => Promise.reject(new Error('tool-catalog provider cannot resume a child')), } ctx.subagents.registerProvider(provider) @@ -388,13 +385,12 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-subagent-control', dir: 'tool-subagent-control', source: 'packages/subagent/tool-subagent-control/src/index.ts', - requires: ['ctx.tools', 'ctx.subagentControl'], - writes: ['tool/call', 'tool/result', 'child session events through the control service'], + requires: ['ctx.tools', 'ctx.subagents'], + writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'], async mount(ctx) { await ctx.plugin(SubagentService) await ctx.plugin(LocalTaskService) await ctx.plugin(AgentRegistry) - await ctx.plugin(SubagentControlService) await ctx.plugin(ToolSubagentControl) }, note: diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 9e8f68177d..10c652f704 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1102,7 +1102,7 @@ { "doc": "docs/core-data-structures/subagent.md", "symbol": "CoordinatorMessageSource", - "source": "packages/subagent/subagent-control/src/index.ts" + "source": "packages/subagent/subagent/src/continuation.ts" }, { "doc": "docs/core-data-structures/subagent.md", diff --git a/tsconfig.host.json b/tsconfig.host.json index 3f46bf5ee6..2e5c6ea08c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -178,7 +178,6 @@ { "path": "./packages/support/loader-smoke" }, { "path": "./packages/support/llm-mock-server" }, { "path": "./packages/subagent/subagent" }, - { "path": "./packages/subagent/subagent-control" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/tool-subagent-control" }, { "path": "./packages/subagent/subagent-inprocess" }, From efc47b6a760539d9ededccc51b00ff4cffe20a14 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 11:50:21 +0800 Subject: [PATCH 039/114] fix(subagent): require durability participant --- ...continuable-background-subagents.i18n.yaml | 4 +-- ...-07-21-continuable-background-subagents.md | 4 +-- ...-21-continuable-background-subagents.zh.md | 4 +-- docs/cordis-catalog/events.md | 9 ++--- docs/cordis-catalog/services.md | 14 +++++++- docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 6 +++- packages/core/session/README.i18n.yaml | 4 +-- packages/core/session/README.md | 1 + packages/core/session/README.zh.md | 1 + packages/core/session/src/index.ts | 27 ++++++++++++-- packages/core/session/tests/scoped.spec.ts | 27 ++++++++++++++ .../subagent-inprocess/README.i18n.yaml | 4 +-- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 2 +- .../tests/subagent-inprocess.spec.ts | 36 ++++++++++++++++++- .../subagent/tests/continuation.spec.ts | 27 ++++++++++++-- 18 files changed, 150 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index d9be549a27..178ed15509 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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-07-21-continuable-background-subagents.md -2026-07-21-continuable-background-subagents.md: 42e9f6dd653dee8df4b8c068e3a33b7c69f8dc4b -2026-07-21-continuable-background-subagents.zh.md: e643e81f40074c3cc0cd6398b3f1d24e0d9bc988 +2026-07-21-continuable-background-subagents.md: fc1cd97dae2583f6d78ee1413561002b6391a0c9 +2026-07-21-continuable-background-subagents.zh.md: 31775d9c2b8bd6b2dc84b5e86d4dbc9d1bd8a1f5 diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index 42e9f6dd65..fc1cd97dae 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -31,7 +31,7 @@ The low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agn ### Task and cancellation ownership -The initial background delegation asks the control service to start the child and register its Task. A continuable provider confirms the activation's final session state durable before fulfilling a successful run result. Task settlement awaits that result, calls `run.dispose()` (through the control service's `settleRun`), and only then records the `TaskOutcome`; `task_kill` aborts the active run, whose settlement path still disposes it. A terminal Task therefore leaves the durable child session but no live child Agent. A failed required durability checkpoint rejects the run with stable code `DURABILITY_FAILED` and the backend failure as its cause; the control service records a failed Task whose detail explains that the latest state was not confirmed persisted and may be unavailable or stale on resume. +The initial background delegation asks the control service to start the child and register its Task. A continuable provider confirms the activation's final session state durable before fulfilling a successful run result. Task settlement awaits that result, calls `run.dispose()` (through the control service's `settleRun`), and only then records the `TaskOutcome`; `task_kill` aborts the active run, whose settlement path still disposes it. A terminal Task therefore leaves the durable child session but no live child Agent. A required durability checkpoint with no installed listener or a failing listener rejects the run with stable code `DURABILITY_FAILED` and the checkpoint failure as its cause; the control service records a failed Task whose detail explains that the latest state was not confirmed persisted and may be unavailable or stale on resume. Every later turn creates another Task. Its producer resources cover only that activation, never the child session. It reaches one terminal status, has one result, and is never reopened. The exact live parent Agent remains the Task registry owner: disposing that instance cancels, awaits, and removes its Tasks. Task APIs authorize a caller whose session id matches that owner, but a same-id replacement does not become the notification or teardown target. This preserves the `settleRun()` contract and bounds Task-owned live children by concurrent work rather than historical session count. @@ -109,7 +109,7 @@ Task records and active-run associations are process-local. Persistence makes th ## Testing -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the service-allocated child id; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above. +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: an absent or detached flush listener and a permanent listener failure reject with `DURABILITY_FAILED`, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the service-allocated child id; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above. - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the configured background route: continuable mode requires a resumable provider and returns both ids without requiring `send_message`, while one-shot mode keeps the plain Task acknowledgement even when the provider can resume. - `packages/sdk/helper/tests/project.spec.ts` pins the Task service and model-facing Task controls in generated spawn and fork compositions. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index e643e81f40..31775d9c2b 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -31,7 +31,7 @@ durable child Session ### Task 与取消的所有权 -初始后台委派请求控制服务启动 child 并注册其 Task。可继续提供方只有在确认本次激活的最终会话状态已持久化后,才会返回成功的 run 结果。Task 结算流程等待该结果,调用 `run.dispose()`(经由控制服务的 `settleRun`),然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。必需的持久性检查点失败时,run 会以稳定错误码 `DURABILITY_FAILED` 拒绝,并将后端失败保留为失败原因;控制服务会记录失败的 Task,其详情说明最新状态未确认已持久化,因此恢复时可能不可用或已陈旧。 +初始后台委派请求控制服务启动 child 并注册其 Task。可继续提供方只有在确认本次激活的最终会话状态已持久化后,才会返回成功的 run 结果。Task 结算流程等待该结果,调用 `run.dispose()`(经由控制服务的 `settleRun`),然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。必需的持久性检查点若没有已安装的监听器或任一监听器失败,run 会以稳定错误码 `DURABILITY_FAILED` 拒绝,并将检查点失败保留为失败原因;控制服务会记录失败的 Task,其详情说明最新状态未确认已持久化,因此恢复时可能不可用或已陈旧。 后续每个轮次都会创建另一个 Task。该轮 producer 持有的执行资源仅服务于这次激活,不属于 child 会话。它只会到达一次终态、只产生一个结果,也不会重新打开。Task 注册表中当前注册的那个存活 parent agent 实例仍是其 owner:dispose 该实例会取消、等待并移除其 Task。Task API 会授权 session id 与该 owner 匹配的调用方,但 id 相同的替代实例不会成为通知或资源清理目标。这一设计保留 `settleRun()` 契约,并使 Task 所拥有的存活 child 数量受并发工作量限制,而不是随历史会话数量增长。 @@ -109,7 +109,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 测试 -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在服务分配的 child id 下持久化;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述契约。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:缺少 flush 监听器、flush 监听器已脱离或监听器持续失败时,均会以 `DURABILITY_FAILED` 拒绝;循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在服务分配的 child id 下持久化;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述契约。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖配置的后台路由:可继续模式要求提供方可恢复,并在不要求 `send_message` 的情况下返回两个 id;即使提供方可以恢复,一次性模式仍保持普通的 Task 确认消息。 - `packages/sdk/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index db6af68970..4624b54dec 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -685,13 +685,14 @@ Source: [`packages/core/session/src/index.ts:93`](../../packages/core/session/sr ### `session/flush` — parallel -Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Dispatch through SessionStore.flush. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. +Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. An empty listener snapshot is accepted by SessionStore.flush and rejected by SessionStore.flushRequired. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. ```ts cordis-catalog /** * Awaited parallel durability checkpoint: every listener runs and the - * caller awaits all of them, with no waterfall veto. Dispatch through - * {@link SessionStore.flush}. Scope-filtered dispatch + * caller awaits all of them, with no waterfall veto. An empty listener + * snapshot is accepted by {@link SessionStore.flush} and rejected by + * {@link SessionStore.flushRequired}. Scope-filtered dispatch * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. * @dshScopeScan unsupported @@ -702,7 +703,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:104`](../../packages/core/session/src/index.ts) ## `settings/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b0d07711f2..3c454115af 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1640,6 +1640,18 @@ announce(session: Session): void */ async flush(session: Session): Promise +/** + * Dispatch the same awaited checkpoint as {@link flush}, but reject when its + * scoped listener snapshot is empty. Callers use this operation when success + * requires an installed durability participant rather than optional + * best-effort persistence. + * @param session - the session whose buffered events must reach durable storage. + * @returns resolves when at least one listener participated and every + * listener settled successfully. + * @throws when no listener is registered or any registered listener fails. + */ +async flushRequired(session: Session): Promise + /** * Look up a live session. * @param id - the session id to look up. @@ -1672,7 +1684,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:765`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:766`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 204bae369d..31f5204a52 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,7 +37,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:104`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 19f7707262..80fb096b4f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -746,6 +746,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async flush(session: Session): Promise', jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when every flush listener has settled; after all settle,\n * rejects with the first registered listener failure if any listener failed.\n */', }, + { + signature: 'async flushRequired(session: Session): Promise', + jsDoc: '/**\n * Dispatch the same awaited checkpoint as {@link flush}, but reject when its\n * scoped listener snapshot is empty. Callers use this operation when success\n * requires an installed durability participant rather than optional\n * best-effort persistence.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when at least one listener participated and every\n * listener settled successfully.\n * @throws when no listener is registered or any registered listener fails.\n */', + }, { signature: 'get(id: SessionId): Session | undefined', jsDoc: '/**\n * Look up a live session.\n * @param id - the session id to look up.\n * @returns the session, or undefined when no live session has that id.\n */', @@ -1410,7 +1414,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'session/flush', mode: 'parallel', signature: '\'session/flush\'(this: Scoped, session: Session): Promise | void', - jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', + jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. An empty listener\n * snapshot is accepted by {@link SessionStore.flush} and rejected by\n * {@link SessionStore.flushRequired}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, { diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 3dc9877266..88ac2bd405 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/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/core/session/README.md -README.md: 9c7d41901e6fb0133fff0e210260e5310a025f75 -README.zh.md: ca1292289901a09b83f9b0a794fa4edc9754b1da +README.md: 59e8694a957e9742a22662766d671dc2145c44e3 +README.zh.md: 7618bc8f3a9146a4fc5afbfb19317deef7f13068 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 9c7d41901e..59e8694a95 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -14,6 +14,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall - `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. - `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. +- `ctx.sessions.flushRequired(session)` uses the same dispatch but also rejects an empty scoped listener snapshot. Callers use it when success requires an installed durability participant rather than optional best-effort persistence. - `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index ca12922899..7618bc8f3a 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -14,6 +14,7 @@ - `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 - `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 +- `ctx.sessions.flushRequired(session)` 沿用相同的分发逻辑,但也会拒绝空的作用域监听器快照。若成功要求已安装的持久性参与方介入,而不是采用可选的尽力持久化,调用方应使用此方法。 - `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 - `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 - `ctx.sessions.get(id: SessionId): Session | undefined` diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 8bbf783bc2..55c3a11e83 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -93,8 +93,9 @@ declare module 'cordis' { 'session/event'(this: Scoped, session: Session, event: SessionEvent): void /** * Awaited parallel durability checkpoint: every listener runs and the - * caller awaits all of them, with no waterfall veto. Dispatch through - * {@link SessionStore.flush}. Scope-filtered dispatch + * caller awaits all of them, with no waterfall veto. An empty listener + * snapshot is accepted by {@link SessionStore.flush} and rejected by + * {@link SessionStore.flushRequired}. Scope-filtered dispatch * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. * @dshScopeScan unsupported @@ -973,9 +974,31 @@ export class SessionStore extends Service { * rejects with the first registered listener failure if any listener failed. */ async flush(session: Session): Promise { + await this.dispatchFlush(session, false) + } + + /** + * Dispatch the same awaited checkpoint as {@link flush}, but reject when its + * scoped listener snapshot is empty. Callers use this operation when success + * requires an installed durability participant rather than optional + * best-effort persistence. + * @param session - the session whose buffered events must reach durable storage. + * @returns resolves when at least one listener participated and every + * listener settled successfully. + * @throws when no listener is registered or any registered listener fails. + */ + async flushRequired(session: Session): Promise { + await this.dispatchFlush(session, true) + } + + /** Dispatch one optional or required flush listener snapshot. */ + private async dispatchFlush(session: Session, requireListener: boolean): Promise { const { carrier } = this.liveEntryFor(session) const callbackArgs: unknown[] = [session] const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session]) + if (requireListener && callbacks.length === 0) { + throw new Error(`session "${session.id}" required durability checkpoint has no registered listener`) + } const results = await Promise.allSettled(callbacks.map((callback) => { try { return callback(...callbackArgs) diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index 7a5e617254..a524d9cb86 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -80,6 +80,33 @@ describe('session dispatch carriers', () => { }) describe('sessions.flush()', () => { + it('allows an ordinary flush with no listeners', async () => { + const ctx = await mount() + const session = ctx.sessions.create() + + await expect(ctx.sessions.flush(session)).resolves.toBeUndefined() + }) + + it('rejects a required flush with no listeners', async () => { + const ctx = await mount() + const session = ctx.sessions.create() + + await expect(ctx.sessions.flushRequired(session)).rejects.toThrow( + `session "${session.id}" required durability checkpoint has no registered listener`, + ) + }) + + it('completes a required flush when a listener succeeds', async () => { + const ctx = await mount() + const session = ctx.sessions.create() + const flushed: Session[] = [] + ctx.on('session/flush', current => void flushed.push(current)) + + await ctx.sessions.flushRequired(session) + + expect(flushed).toEqual([session]) + }) + it('dispatches session/flush with the owning carrier and awaits all listeners', async () => { const ctx = await mount() const scope = await mintScope(ctx, 'owner') diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 3cf7ffb480..10e2e40108 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: 1bbbfd282fe98f73b1828b22a95efd34e5ddc0ab -README.zh.md: d6dc91415beb3986ad226a8467ce2abbabce8591 +README.md: afc92cf4f38830c22a2de401620e0223e7bf62d1 +README.zh.md: dcf7d343901145f63758bfce0f85fa70691cb14e diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 1bbbfd282f..afc92cf4f3 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,7 +14,7 @@ The driver follows this sequence: 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result. This final confirmation retries events retained after a failed turn checkpoint; if it still fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the backend failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior. +5. For a continuable start or resume, call `child.ctx.sessions.flushRequired(child.session)` again before returning the result. This final confirmation requires an installed durability listener and retries events retained after a failed turn checkpoint; if no listener participates or any listener fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the checkpoint failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior. 6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records. The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index d6dc91415b..dcf7d34390 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 对于可继续的启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`。这次最终确认会重试轮次检查点失败后保留的事件;若仍然失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,保留后端失败作为 `cause`,并在消息中指出可恢复性风险。在这次等待期间取消 activation 时,即使已记录完成的轮次,或检查点随后失败,取消仍决定尚未发布的结果。前台运行仍采用循环的尽力而为检查点行为。 +5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flushRequired(child.session)`。这次最终确认要求有已安装的持久性监听器参与,并会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。 6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 20c258e195..a17cd15be1 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -270,7 +270,7 @@ function driveTurn( await child.whenIdle() if (durability === 'required') { try { - await child.ctx.sessions.flush(child.session) + await child.ctx.sessions.flushRequired(child.session) } catch (error: unknown) { if (!signal.aborted) { throw new SubagentError( diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 50f8b03370..d052a3a1e1 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -73,6 +73,40 @@ describe('startInProcessRun', () => { expect(ctx.agents.get(run.id)).toBeUndefined() }) + it('rejects a continuable child when no durability listener is registered', async () => { + const { parent } = await setup([textResponse('driver answer')]) + + const run = await startInProcessRun(continuableRequest(parent), {}) + const caught: unknown = await run.result.catch((error: unknown) => error) + + expect(caught).toBeInstanceOf(SubagentError) + const durabilityError = caught as SubagentError + expect(durabilityError.code).toBe('DURABILITY_FAILED') + expect(durabilityError.message).toContain('required durability checkpoint has no registered listener') + await run.dispose() + }) + + it('rejects when the durability listener disappears before final confirmation', async () => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + let flushes = 0 + let detach = (): void => {} + detach = ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined) return + flushes++ + if (flushes === 1) detach() + }) + + const run = await startInProcessRun(continuableRequest(parent), {}) + const caught: unknown = await run.result.catch((error: unknown) => error) + + expect(caught).toBeInstanceOf(SubagentError) + const durabilityError = caught as SubagentError + expect(durabilityError.code).toBe('DURABILITY_FAILED') + expect(durabilityError.message).toContain('required durability checkpoint has no registered listener') + expect(flushes).toBe(1) + await run.dispose() + }) + it('requires a final durability checkpoint for a continuable child', async () => { const { ctx, parent } = await setup([textResponse('driver answer')]) const failure = new Error('disk full') @@ -312,7 +346,7 @@ describe('startInProcessRun', () => { acceptsNextStep: false, ctx: { sessions: { - flush: () => { + flushRequired: () => { flushes++ return Promise.resolve() }, diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 5a0d20c481..ede4726c5e 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -61,10 +61,12 @@ afterEach(() => { async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + let disposePersistence: (() => Promise) | undefined if (options.persistence !== false) { const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) roots.push(root) - await ctx.plugin(JsonlSessionPersistence, { root }) + const persistenceFiber = await ctx.plugin(JsonlSessionPersistence, { root }) + disposePersistence = () => persistenceFiber.dispose() } await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) @@ -74,7 +76,7 @@ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } await ctx.plugin(ToolTasks, {}) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - return { ctx, parent } + return { ctx, parent, disposePersistence } } async function setup(script: Script, options: { persistence?: boolean } = {}) { @@ -141,6 +143,25 @@ describe('SubagentService.startContinuable', () => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) + it('fails the Task when persistence detaches before the activation completes', async () => { + const releaseResponse = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('unconfirmed answer'), gate: releaseResponse.promise }, + ]) + const { ctx, parent, disposePersistence } = await setupWith(adapter) + const started = ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + + await disposePersistence!() + releaseResponse.resolve(undefined) + + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('durability checkpoint failed') + expect(snapshot.detail).toContain('required durability checkpoint has no registered listener') + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) + it('publishes the service-allocated child id and appends the turn-enclosed descriptor', async () => { const { ctx, parent } = await setup([textResponse('answer')]) const seen: SessionEvent[] = [] From ddab8b84c00af41c80524731ad8f351d914e309c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 12:43:23 +0800 Subject: [PATCH 040/114] test(goal-session): cover unsettled attempt yield --- .../goal-session/tests/goal-session.spec.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 5a3f0bdca2..073cb5002f 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -9,7 +9,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal' import { createUserMessage, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import type { TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' import * as goalSession from '../src/index.ts' declare module '@deepseek-ai/dsh-session' { @@ -787,6 +787,35 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(1) }) + it('yields to a round whose turn/end never committed instead of misreading it as settled', async () => { + const test = await harness([textResponse('round ran')]) + // A persistent pre-commit turn/end rejection reaches idle with the + // attempt's turn open and no terminal reason. The driver must yield + // instead of clearing the reservation or scheduling another round. + let roundTurn: number | undefined + test.ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as SessionEvent + if (event.type === 'turn/start' && event.data.trigger.kind === 'message' + && event.data.trigger.source.kind === 'goal') { + roundTurn = event.data.turn + } + if (event.type === 'turn/end' && event.data.turn === roundTurn) { + throw new Error('turn close permanently rejected') + } + }) + test.ctx.goals.create(test.agent, { objective: 'survive a lost turn end' }) + await waitForRequests(test.adapter, 1) + await test.agent.whenIdle() + await new Promise((resolve) => { setImmediate(resolve) }) + + expect(test.adapter.requests).toHaveLength(1) + expect(test.ctx.goals.get(test.agent)).toMatchObject({ + phase: 'active', + activation: 'armed', + }) + }) + it('cancels an accepted queued round and awaits its driver task during teardown', async () => { const test = await harness([]) let unloading: Promise | undefined From 0821ceb03337f2d44a7023b1c0809c47e15afd41 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 13:20:28 +0800 Subject: [PATCH 041/114] fix(sdk): mount local task registry for subagents --- packages/sdk/helper/src/features/builtin/index.ts | 2 +- packages/sdk/helper/tests/project.spec.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 139eae3bde..2aa6c40e71 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -212,7 +212,7 @@ config: // In-process options select continuable background delegation; the // follow-up adapter remains an independently loadable global tool. baseResources: [ - { kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks' }, + { kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks-local' }, { kind: 'npm-cordis-config-entry', id: 'tool-tasks', package: '@deepseek-ai/dsh-tool-tasks' }, { kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }, { kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' }, diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index d006465e77..763558afab 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -206,12 +206,13 @@ describe('SdkProject and ProjectEditSession', () => { it.each(['spawn', 'fork'] as const)('mounts Task controls for %s subagents', async (option) => { const project = await createCommitted([selection('subagent', [option])]) - expect(project.cordis.entry('tasks')?.name).toBe('@deepseek-ai/dsh-tasks') + expect(project.cordis.entry('tasks')?.name).toBe('@deepseek-ai/dsh-tasks-local') expect(project.cordis.entry('tool-tasks')?.name).toBe('@deepseek-ai/dsh-tool-tasks') expect(project.packageManifest().dependencies).toMatchObject({ - '@deepseek-ai/dsh-tasks': '^0.0.1', + '@deepseek-ai/dsh-tasks-local': '^0.0.1', '@deepseek-ai/dsh-tool-tasks': '^0.0.1', }) + expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-tasks') }) it('round-trips embed app projects without a front-door Cordis config entry', async () => { From 112e25bb054f49eabc97d6234a70599a4e94e4d7 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 14:08:15 +0800 Subject: [PATCH 042/114] refactor(subagent): unify service errors --- ...6-merge-subagent-control-service.i18n.yaml | 4 +-- ...26-07-26-merge-subagent-control-service.md | 2 ++ ...07-26-merge-subagent-control-service.zh.md | 2 ++ docs/cordis-catalog/services.md | 2 +- .../subagent-continuable/session.jsonl | 4 +-- .../subagent/subagent/src/continuation.ts | 33 ++++++++----------- packages/subagent/subagent/src/error.ts | 15 +++++++++ packages/subagent/subagent/src/index.ts | 12 ++----- .../subagent/tests/continuation.spec.ts | 4 +-- 9 files changed, 41 insertions(+), 37 deletions(-) create mode 100644 packages/subagent/subagent/src/error.ts diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml index 1068f5f578..fe5d14796a 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.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-26-merge-subagent-control-service.md -2026-07-26-merge-subagent-control-service.md: a5ce673997502ef6fbd7c66ff4e94e301d4192ba -2026-07-26-merge-subagent-control-service.zh.md: cf867ab444438b7ee62cde68a3d53e83c3d049d1 +2026-07-26-merge-subagent-control-service.md: eb8a76dd4dfc5f06deb67608a67c12e061819286 +2026-07-26-merge-subagent-control-service.zh.md: 6599606634a1933790949e8a66df906a0bb9def0 diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md index a5ce673997..eb8a76dd4d 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md @@ -12,6 +12,8 @@ Continuable-child orchestration originally lived in a separate `ctx.subagentCont `SubagentService` is the only public service. It retains raw `start(name, request)` and `resume(name, request)` for callers that own run collection, and exposes `startContinuable(spec)` and `sendMessage(...)` for durable Task-backed activations. The standalone `@deepseek-ai/dsh-subagent-control` package and `ctx.subagentControl` key are deleted; the optional `@deepseek-ai/dsh-tool-subagent-control` package injects `ctx.subagents` directly. +The merged service and its providers expose one `SubagentError` taxonomy. Stable codes distinguish provider lookup and capability failures from continuation routing, authorization, cancellation, persistence, and delivery failures; the removed service does not retain a separate error class. + The continuation implementation remains an internal manager rather than expanding the provider registry's core state. `SubagentService` creates it through `ctx.inject(['tasks', 'agents'], ...)`, so the injected Cordis child fiber owns its Task completion listener and teardown effects. Loading the provider registry does not require Tasks or persistence. The manager exists only while Tasks and Agents are available, and each continuation operation resolves session persistence at the point it needs durability. Disposing that fiber cancels and settles active continuations before releasing their associations. `startContinuable` remains distinct from raw `start` because it has a different ownership and timing contract: it allocates the durable child id, creates the Task, and returns both ids synchronously while startup continues inside the Task. Raw `start` instead awaits provider publication and transfers a holder-owned run. Folding the method onto `start` through flags or return unions would broaden the low-level contract and create more change than keeping the existing explicit entry. diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md index cf867ab444..6599606634 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md @@ -12,6 +12,8 @@ Status: implemented `SubagentService` 是唯一的公开服务。它为自行收集 run 的调用方保留底层 `start(name, request)` 和 `resume(name, request)`,并公开 `startContinuable(spec)` 与 `sendMessage(...)`,用于具备持久性、由 Task 支撑的激活。系统删除独立的 `@deepseek-ai/dsh-subagent-control` 包(package)和 `ctx.subagentControl` 键;可选的 `@deepseek-ai/dsh-tool-subagent-control` 包则直接注入 `ctx.subagents`。 +合并后的服务及其提供方公开一套 `SubagentError` 分类体系。稳定错误码把提供方查找失败和功能检查失败,与继续执行路由、鉴权、取消、持久化和送达失败区分开来;已移除的服务不保留单独的错误类。 + 继续执行的实现仍是内部管理器,不会扩展提供方注册表的核心状态。`SubagentService` 通过 `ctx.inject(['tasks', 'agents'], ...)` 创建该管理器,因此注入的 Cordis child fiber 拥有自身的 Task 完成监听器和拆卸 effect。加载提供方注册表不要求 Task 或持久化。只有 Task 和 Agent 可用时,该管理器才会存在;每项继续执行操作都在需要持久性时解析会话持久化服务。dispose(资源释放)该 fiber 会先取消并结算活跃的继续执行,再释放其关联。 `startContinuable` 与底层 `start` 保持分离,因为二者的所有权与时序契约不同:前者分配持久化 child id、创建 Task,并同步返回两个 id,而启动过程继续在 Task 内运行;底层 `start` 则等待提供方发布,并移交一个由持有方负责的 run。若通过标志或返回值联合类型将该方法并入 `start`,会扩大底层契约,改动反而多于保留现有的显式入口。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3c454115af..9cb67d757d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2030,7 +2030,7 @@ async resume(name: string, request: SubagentResumeRequest): Promise Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentResumeRequest](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:206`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:198`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index 1b6e576b54..e34205b0f2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -35,7 +35,7 @@ {"type":"tool/call","seq":33,"time":1785517567431,"data":{"turn":1,"step":3,"callId":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} {"type":"tool/result","seq":34,"time":1785517567438,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_follow_up"},"content":[{"type":"tool-result","toolCallId":"call_follow_up","content":[{"type":"text","text":"message started task subagent-2 continuing subagent 22222222-2222-4222-8222-222222222222"}],"isError":false}],"role":"user","id":"6a7a5d22-1172-4a10-9230-ec12aed58e5e"}},"sourceEventSeqs":[33],"surfaceOp":"append"} {"type":"step/end","seq":35,"time":1785517567438,"data":{"turn":1,"step":3}} -{"type":"user/message","seq":36,"time":1785517567444,"data":{"content":[{"type":"text","text":"background task subagent-2 (subagent: Please continue.) finished [status: failed, SubagentControlError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]. Read its output with task_output."}],"source":{"kind":"plugin","plugin":"tool-tasks"},"role":"user","id":"32644e35-5ea1-4d29-8ef6-e09eb813781c"},"surfaceOp":"append"} +{"type":"user/message","seq":36,"time":1785517567444,"data":{"content":[{"type":"text","text":"background task subagent-2 (subagent: Please continue.) finished [status: failed, SubagentError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]. Read its output with task_output."}],"source":{"kind":"plugin","plugin":"tool-tasks"},"role":"user","id":"32644e35-5ea1-4d29-8ef6-e09eb813781c"},"surfaceOp":"append"} {"type":"step/start","seq":37,"time":1785517567444,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":38,"time":1789000000037,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":39,"time":1789000000038,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_collect_2","name":"task_output","argumentsDelta":"{\"task_id\": \"subagent-2\", \"wait\": true}"}}} @@ -44,7 +44,7 @@ {"type":"assistant/chunk","seq":42,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":43,"time":1785517567453,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"31137fd0-a07c-4d5f-b847-6dbb33e86305"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} {"type":"tool/call","seq":44,"time":1785517567454,"data":{"turn":1,"step":4,"callId":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}} -{"type":"tool/result","seq":45,"time":1785517567460,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_collect_2"},"content":[{"type":"tool-result","toolCallId":"call_collect_2","content":[{"type":"text","text":"(no new output)\n[status: failed, SubagentControlError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]"}],"isError":false}],"role":"user","id":"21807217-0a28-4369-868c-c2480398e883"}},"sourceEventSeqs":[44],"surfaceOp":"append"} +{"type":"tool/result","seq":45,"time":1785517567460,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_collect_2"},"content":[{"type":"tool-result","toolCallId":"call_collect_2","content":[{"type":"text","text":"(no new output)\n[status: failed, SubagentError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]"}],"isError":false}],"role":"user","id":"21807217-0a28-4369-868c-c2480398e883"}},"sourceEventSeqs":[44],"surfaceOp":"append"} {"type":"step/end","seq":46,"time":1785517567460,"data":{"turn":1,"step":4}} {"type":"step/start","seq":47,"time":1785517567467,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":48,"time":1789000000047,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 533cf15332..15bb14bbe4 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -24,6 +24,7 @@ import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor import type { SubagentResult, SubagentRun, SubagentStartRequest } from './types.ts' import type { SubagentService } from './index.ts' import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks' +import { SubagentError } from './error.ts' /** Attribution for a model coordinator's follow-up to one of its children. */ export interface CoordinatorMessageSource { @@ -38,14 +39,6 @@ declare module '@deepseek-ai/dsh-llm' { } } -/** Typed error for continuation routing, authorization, and delivery failures. */ -export class SubagentControlError extends HarnessError { - constructor(message: string, code: string, options?: ErrorOptions) { - super(message, code, options) - this.name = 'SubagentControlError' - } -} - /** What a caller asks for when starting a continuable background child. */ export interface ContinuableStartSpec { /** The `ctx.subagents` provider to establish the child on. */ @@ -285,13 +278,13 @@ export class SubagentContinuationManager { if (live === undefined) return const activation = this.activations.get(childId) if (activation === undefined) { - throw new SubagentControlError( + throw new SubagentError( `subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`, 'OWNERSHIP_CONFLICT', ) } if (activation.run !== undefined && activation.run.localAgent !== live) { - throw new SubagentControlError( + throw new SubagentError( `subagent "${childId}" registry agent is not the associated activation's agent; the message was not delivered`, 'OWNERSHIP_CONFLICT', ) @@ -309,12 +302,12 @@ export class SubagentContinuationManager { const taskId = activation.taskId /* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */ if (taskId === undefined) { - throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') + throw new SubagentError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') } // Owner-session authorization plus the live status for admission. const snapshot = this.ctx.tasks.get(taskId, parent) if (snapshot.status !== 'running') { - throw new SubagentControlError( + throw new SubagentError( `subagent "${childId}" task ${taskId} is ${snapshot.status}; the message was not delivered ` + '— retry after it settles to start the next activation', 'NOT_DELIVERED', @@ -322,10 +315,10 @@ export class SubagentContinuationManager { } const run = activation.run if (run === undefined) { - throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') + throw new SubagentError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') } if (run.steer === undefined) { - throw new SubagentControlError( + throw new SubagentError( `subagent "${childId}" provider does not accept live delivery; the message was not delivered`, 'NOT_DELIVERED', ) @@ -336,7 +329,7 @@ export class SubagentContinuationManager { // Confirmed steering lost the race with request admission. Deliberately no // cold-resume fallback here: that would attach the message to a turn the // caller did not observe. - throw new SubagentControlError( + throw new SubagentError( `subagent "${childId}" stopped before delivery; the message was not delivered`, 'NOT_DELIVERED', { cause: error }, @@ -364,18 +357,18 @@ export class SubagentContinuationManager { try { loaded = await persistence.load(childId) } catch (error: unknown) { - throw new SubagentControlError( + throw new SubagentError( `subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }, ) } // The persistence seam takes no signal; recheck before any child work. - if (signal.aborted) throw new SubagentControlError('subagent resume was cancelled during lookup', 'CANCELLED') + if (signal.aborted) throw new SubagentError('subagent resume was cancelled during lookup', 'CANCELLED') // Authorize the persisted header before folding: only the direct parent // recorded at creation may continue this child. if (loaded.meta.parentSession !== parent.id) { - throw new SubagentControlError( + throw new SubagentError( `subagent "${childId}" belongs to another parent session`, 'UNAUTHORIZED', ) @@ -385,7 +378,7 @@ export class SubagentContinuationManager { // itself a continuable child. const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0)) if (descriptor === undefined) { - throw new SubagentControlError( + throw new SubagentError( `subagent "${childId}" has no supported continuation state and cannot be resumed; ` + 'do not retry send_message with this id', 'NOT_RESUMABLE', @@ -477,7 +470,7 @@ export class SubagentContinuationManager { private requirePersistence(): SessionPersistence { const persistence = this.ctx.get('sessionPersistence') if (persistence === undefined) { - throw new SubagentControlError( + throw new SubagentError( 'continuable subagents require session persistence (load a dsh-session-persistence backend)', 'PERSISTENCE_UNAVAILABLE', ) diff --git a/packages/subagent/subagent/src/error.ts b/packages/subagent/subagent/src/error.ts new file mode 100644 index 0000000000..96409074f5 --- /dev/null +++ b/packages/subagent/subagent/src/error.ts @@ -0,0 +1,15 @@ +/** + * Typed failures shared by subagent service and provider operations. + * + * @module @deepseek-ai/dsh-subagent + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** Typed failure for the subagent seam. */ +export class SubagentError extends HarnessError { + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'SubagentError' + } +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index f81da156eb..8809e6d4ea 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -32,7 +32,6 @@ import { Context, Service } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' -import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -45,6 +44,7 @@ import type { SubagentStartRequest, } from './types.ts' import { SubagentRunId } from './types.ts' +import { SubagentError } from './error.ts' import SubagentContinuationManager from './continuation.ts' import type { ContinuableStart, @@ -71,10 +71,10 @@ export { SUBAGENT_DESCRIPTOR_VERSION, } from './descriptor.ts' export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts' +export { SubagentError } from './error.ts' export { runOutcome, settleRun, - SubagentControlError, } from './continuation.ts' export type { ContinuableStart, @@ -194,14 +194,6 @@ export interface SubagentRunEndInfo { readonly lastAssistantMessage?: ContentBlock[] } -/** Typed error for provider lookup, registration, and capability failures. */ -export class SubagentError extends HarnessError { - constructor(message: string, code: string, options?: ErrorOptions) { - super(message, code, options) - this.name = 'SubagentError' - } -} - /** Named provider registry with raw and Task-backed continuation operations. */ export class SubagentService extends Service { private providers = new Map() diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index ede4726c5e..fec3532e2c 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -20,7 +20,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent import SubagentService, { runOutcome, settleRun, - SubagentControlError, + SubagentError, SUBAGENT_DESCRIPTOR_VERSION, } from '../src/index.ts' @@ -625,7 +625,7 @@ describe('SubagentService.sendMessage', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) - .rejects.toThrow(SubagentControlError) + .rejects.toThrow(SubagentError) await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) .rejects.toThrow(/outside continuation ownership.*not delivered/) await handle.dispose() From 52002791a47024589efd15745d8b7c886110dd9b Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 15:36:17 +0800 Subject: [PATCH 043/114] test(acp): stabilize continuable subagent snapshot --- .../fixtures/subagent-durability-failure.ts | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 5d0137911d..47f96c0b80 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -1,9 +1,47 @@ import type { Context } from 'cordis' export const name = 'subagent-durability-failure' +export const inject = ['sessionPersistence', 'tasks'] -/** Fail a continuable child's provider-owned final durability confirmation. */ +const UNKNOWN_CHILD_ID = '22222222-2222-4222-8222-222222222222' +const FOLLOW_UP_TASK_ID = 'subagent-2' + +/** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */ export function apply(ctx: Context): void { + const thirdStepEnded = Promise.withResolvers() + const followUpSettled = Promise.withResolvers() + const persistence = ctx.sessionPersistence + const load = persistence.load.bind(persistence) + + // The unavailable-child lookup is real asynchronous I/O. Fence it between + // the authored step boundaries so runner speed cannot reorder the exact log. + persistence.load = async (id) => { + if (id === UNKNOWN_CHILD_ID) await thirdStepEnded.promise + return load.call(persistence, id) + } + ctx.effect(() => () => { + persistence.load = load + thirdStepEnded.resolve(undefined) + followUpSettled.resolve(undefined) + }, 'subagent snapshot ordering') + + ctx.on('session/event', (session, event) => { + if (session.header.parentSession === undefined + && event.type === 'step/end' + && event.data.turn === 1 + && event.data.step === 3) { + thirdStepEnded.resolve(undefined) + } + }) + ctx.tasks.onTaskDone((snapshot) => { + if (snapshot.id === FOLLOW_UP_TASK_ID) followUpSettled.resolve(undefined) + }) + ctx.on('agent/step', async (agent, turn, step) => { + if (agent.session.header.parentSession === undefined && turn === 1 && step === 4) { + await followUpSettled.promise + } + }) + const flushedTurnEnds = new WeakSet() ctx.on('session/flush', (session) => { if (session.header.parentSession === undefined) return From fea31a012ddc171498ff775d4a867c71e2b21b58 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 17:54:36 +0800 Subject: [PATCH 044/114] fix(subagent): persist descriptor before admission --- ...continuable-background-subagents.i18n.yaml | 4 +-- ...-07-21-continuable-background-subagents.md | 4 +-- ...-21-continuable-background-subagents.zh.md | 4 +-- docs/event-producer-consumer.md | 4 +-- .../subagent-continuable/session.1.jsonl | 8 +++--- .../subagent-inprocess/README.i18n.yaml | 4 +-- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 17 ++++++------ .../subagent/tests/continuation.spec.ts | 27 +++++++++++++++++-- 10 files changed, 49 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 178ed15509..20ec58ca97 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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-07-21-continuable-background-subagents.md -2026-07-21-continuable-background-subagents.md: fc1cd97dae2583f6d78ee1413561002b6391a0c9 -2026-07-21-continuable-background-subagents.zh.md: 31775d9c2b8bd6b2dc84b5e86d4dbc9d1bd8a1f5 +2026-07-21-continuable-background-subagents.md: 5bbc5fb0b605771b0e7c292412d13ec6e56571ba +2026-07-21-continuable-background-subagents.zh.md: 1864f6d47fe95d9771bb73da72caee6bb415ce0f diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index fc1cd97dae..5bbc5fb0b6 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -71,7 +71,7 @@ Human input uses the same control operation. The UI may display the child transc ### Durable child handle and cold resume -The control service snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts)) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution — a one-shot `agent/pre-step` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event after the initial child `turn/start` and before its first request; it carries no `surfaceOp`, remains outside model history, and reaches persistence with that turn's flush. The append-only log retains this non-surface event when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) and its header identifies the caller as the direct parent. +The control service snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts)) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution — a prepended one-shot `agent/prompt-submit` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event before downstream prompt admission can block or throw. Allowed admission opens the initial child turn afterward; rejected admission leaves the descriptor as a pre-turn log-only fact, and the activation's final required checkpoint persists it. The event carries no `surfaceOp`, remains outside model history, and survives when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) and its header identifies the caller as the direct parent. The versioned descriptor (`SUBAGENT_DESCRIPTOR_VERSION` in [descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts)) contains the subagent provider name, resolved child `agentOptions.provider` and `agentOptions.model`, and optional `persona` and `toolFilter`. It does not snapshot the merge-extensible `AgentOptions` object: unrelated extension values cannot make continuation fail merely because they are not JSON. It deliberately omits `subagentDepth`; cold resume relies on the persisted header's `delegationDepth` rather than reconstructing depth from the descriptor. `outputSchema` belongs to one activation's result contract rather than durable child composition. The child header remains authoritative for the child id, `cwd`, `parentSession`, `seedLength`, and `delegationDepth`, while the persisted child transcript owns the fork seed and subsequent history. [`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) takes the maximum of header and runtime values, so reconstructed runtime options may deepen the persisted value but never lower it and a resumed child cannot regain a top-level delegation budget. @@ -109,7 +109,7 @@ Task records and active-run associations are process-local. Persistence makes th ## Testing -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: an absent or detached flush listener and a permanent listener failure reject with `DURABILITY_FAILED`, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the service-allocated child id; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above. +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: an absent or detached flush listener and a permanent listener failure reject with `DURABILITY_FAILED`, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is pre-turn, model-hidden, versioned, durable under the service-allocated child id, and survives blocked or throwing initial prompt admission; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above. - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the configured background route: continuable mode requires a resumable provider and returns both ids without requiring `send_message`, while one-shot mode keeps the plain Task acknowledgement even when the provider can resume. - `packages/sdk/helper/tests/project.spec.ts` pins the Task service and model-facing Task controls in generated spawn and fork compositions. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 31775d9c2b..1864f6d47f 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -71,7 +71,7 @@ durable child Session ### 持久化 child handle 与从持久化存储恢复 -控制服务在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动安装的一次性 `agent/pre-step` 监听器——会在 child 初始 `turn/start` 之后、首次请求之前追加一个对模型隐藏的 `subagent/descriptor` 事件。该事件不携带 `surfaceOp`,不进入模型历史,并随该轮次的 flush 一并进入持久化存储。当压缩替换 surface 历史时,仅追加日志仍保留这个不属于 surface 的事件。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 +控制服务在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 版本化描述符([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)包含 subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果契约,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`seedLength` 和 `delegationDepth` 的权威信息,持久化 child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。 @@ -109,7 +109,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 测试 -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:缺少 flush 监听器、flush 监听器已脱离或监听器持续失败时,均会以 `DURABILITY_FAILED` 拒绝;循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在服务分配的 child id 下持久化;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述契约。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:缺少 flush 监听器、flush 监听器已脱离或监听器持续失败时,均会以 `DURABILITY_FAILED` 拒绝;循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次前、对模型隐藏、带版本、在服务分配的 child id 下持久化,并在初始 prompt admission 阻止请求或抛出异常时仍保留;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述契约。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖配置的后台路由:可继续模式要求提供方可恢复,并在不要求 `send_message` 的情况下返回两个 id;即使提供方可以恢复,一次性模式仍保持普通的 Task 确认消息。 - `packages/sdk/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 31f5204a52..fef8b22088 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -16,13 +16,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | | `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:317`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui`](../packages/ui/tui) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:402`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:421`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | | `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:450`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:389`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:389`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index c349369a85..15e024fa07 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -1,8 +1,8 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1789000000000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1789000000001,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"5eabc0cb-6297-4988-92d9-554fb1cfdab7"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1789000000001,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1784795691405,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"subagent/descriptor","seq":0,"time":1784795691405,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"turn/start","seq":1,"time":1789000000000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":2,"time":1789000000001,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"5eabc0cb-6297-4988-92d9-554fb1cfdab7"},"surfaceOp":"append"} +{"type":"session/title","seq":3,"time":1789000000001,"data":{"title":"Reply with exactly the word","messageSeqs":[2],"source":{"kind":"fallback"}}} {"type":"user/message","seq":4,"time":1785517567401,"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":"57bfffb1-f18b-4e29-aaca-26ecaea51574"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785517567401,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785517567401,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 10e2e40108..89d9134fcb 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: afc92cf4f38830c22a2de401620e0223e7bf62d1 -README.zh.md: dcf7d343901145f63758bfce0f85fa70691cb14e +README.md: 525760ccc413bb46ca5ea3a37e610a3ff58b8068 +README.zh.md: 4d02e2bb89f38e449dfd8bf31a39b79891f6a69e diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index afc92cf4f3..525760ccc4 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -12,7 +12,7 @@ The driver follows this sequence: 1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header. 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id. -3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush. +3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the prepended one-shot `agent/prompt-submit` contribution. It appends the `subagent/descriptor` event before downstream prompt admission can block or throw; allowed admission opens the initial turn afterward, while the final required checkpoint persists the descriptor even when no turn opens. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. 5. For a continuable start or resume, call `child.ctx.sessions.flushRequired(child.session)` again before returning the result. This final confirmation requires an installed durability listener and retries events retained after a failed turn checkpoint; if no listener participates or any listener fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the checkpoint failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior. 6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index dcf7d34390..4d02e2bb89 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -12,7 +12,7 @@ 1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 -3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。 +3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会前置安装一次性的 `agent/prompt-submit` 贡献。它会在下游 prompt admission 能够阻止请求或抛出异常之前追加 `subagent/descriptor` 事件;admission 获准后才会开启初始轮次,即使没有轮次开启,最终的必需检查点仍会持久化该描述符。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flushRequired(child.session)`。这次最终确认要求有已安装的持久性监听器参与,并会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。 6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index a17cd15be1..5e6abdda06 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -85,17 +85,16 @@ function prePublicationAbort(): Error { /** * Register the one-shot child-scoped contribution that appends the durable - * `subagent/descriptor` event. `agent/step` is the first serial seam - * inside the child's initial turn, so the append lands after `turn/start` and - * before the first request, and reaches persistence with that turn's flush. + * `subagent/descriptor` event. The prepended `agent/prompt-submit` wrapper + * appends before downstream admission can block or throw. Allowed admission + * opens the initial turn afterward; the final required checkpoint also + * persists the descriptor when no turn opens. */ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { - let appended = false - childCtx.on('agent/step', (agent) => { - if (appended) return - appended = true + childCtx.once('agent/prompt-submit', (agent, _message, _signal, next) => { agent.session.append('subagent/descriptor', descriptor) - }) + return next() + }, { prepend: true }) } /** @@ -103,7 +102,7 @@ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescripto * already published in the registry; rejection means the agent factory's * creation transaction and any partially-created child have reached quiescence. * A `request.continuation` publishes exactly its stable child id and appends - * its descriptor inside the child's initial turn. + * its descriptor before the child's initial prompt admission. * @param request - the trusted typed start request, including its required signal. * @param options - the optional fork seed. * @returns a ready holder-owned run. diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index fec3532e2c..1ef761881d 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -162,7 +162,7 @@ describe('SubagentService.startContinuable', () => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) - it('publishes the service-allocated child id and appends the turn-enclosed descriptor', async () => { + it('publishes the service-allocated child id and appends the pre-turn descriptor', async () => { const { ctx, parent } = await setup([textResponse('answer')]) const seen: SessionEvent[] = [] ctx.on('session/event', (session, event) => { @@ -174,7 +174,7 @@ describe('SubagentService.startContinuable', () => { const descriptorIndex = seen.findIndex(event => event.type === 'subagent/descriptor') const turnStartIndex = seen.findIndex(event => event.type === 'turn/start') const firstAssistant = seen.findIndex(event => event.type === 'assistant/message') - expect(descriptorIndex).toBeGreaterThan(turnStartIndex) + expect(descriptorIndex).toBeLessThan(turnStartIndex) expect(descriptorIndex).toBeLessThan(firstAssistant) const descriptor = seen[descriptorIndex] as SessionEvent<'subagent/descriptor'> expect(descriptor.data).toEqual({ @@ -193,6 +193,29 @@ describe('SubagentService.startContinuable', () => { expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) }) + it.each(['block', 'throw'] as const)( + 'persists the descriptor before initial prompt admission can $0', + async (outcome) => { + const { ctx, parent, adapter } = await setup([]) + ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => { + if (subject === parent) return next() + if (outcome === 'block') return { kind: 'block', reason: 'blocked by policy' } + throw new Error('prompt admission failed') + }) + + const started = ctx.subagents.startContinuable(startSpec(parent)) + const snapshot = await waitTerminal(ctx, started.taskId, parent) + + expect(snapshot.status).toBe('failed') + expect(adapter.requests).toEqual([]) + const loaded = await ctx.sessionPersistence.load(started.childId) + const descriptorIndexes = loaded.events.flatMap((event, index) => + event.type === 'subagent/descriptor' ? [index] : []) + expect(descriptorIndexes).toHaveLength(1) + expect(loaded.events.some(event => event.type === 'turn/start')).toBe(false) + }, + ) + it('rejects synchronously with no Task when persistence is not configured', async () => { const { ctx, parent } = await setup([textResponse('unused')], { persistence: false }) expect(() => ctx.subagents.startContinuable(startSpec(parent))) From 644bf00b86b054d9eb1689d2db86a6d42a6369ad Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 19:49:22 +0800 Subject: [PATCH 045/114] fix(subagent): cancel pending live delivery --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 4 +- ...-21-continuable-background-subagents.zh.md | 4 +- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/subagent.i18n.yaml | 6 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 122 +++++++++++++++--- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 52 ++++---- .../subagent/subagent/src/continuation.ts | 40 +++++- packages/subagent/subagent/src/index.ts | 5 +- .../subagent/tests/continuation.spec.ts | 28 +++- .../tool-subagent-control/README.i18n.yaml | 6 + .../subagent/tool-subagent-control/README.md | 4 +- .../tool-subagent-control/README.zh.md | 42 ++++++ .../tool-subagent-control/src/index.ts | 1 + .../tests/tool-subagent-control.spec.ts | 51 +++++++- 19 files changed, 320 insertions(+), 65 deletions(-) create mode 100644 packages/subagent/tool-subagent-control/README.i18n.yaml create mode 100644 packages/subagent/tool-subagent-control/README.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 20ec58ca97..20623c2641 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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-07-21-continuable-background-subagents.md -2026-07-21-continuable-background-subagents.md: 5bbc5fb0b605771b0e7c292412d13ec6e56571ba -2026-07-21-continuable-background-subagents.zh.md: 1864f6d47fe95d9771bb73da72caee6bb415ce0f +2026-07-21-continuable-background-subagents.md: 4c2cc4ce2538a3d1cf6756168fe4dc1a6448d22a +2026-07-21-continuable-background-subagents.zh.md: 019b623d447781bf254cb241c1b8f1c64fd49c4a diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index 5bbc5fb0b6..4c2cc4ce25 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -39,7 +39,7 @@ Opening a child session in a human-facing adapter reads its persisted transcript `TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. SDK-generated spawn and fork compositions mount `@deepseek-ai/dsh-tasks` and `@deepseek-ai/dsh-tool-tasks` with the subagent control pair. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations. -Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as `killed`; the messages do not have independent results or cancellation rights. Independent cancellation requires a later message to start a separate turn instead of steering the current one. +Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as `killed`; the messages do not have independent results or cancellation rights. `sendMessage()` requires the caller's signal, and abort while live steering awaits request admission aborts the activation-owned controller so the provider discards the pending message and the call settles only after the child reaches quiescence. Independent cancellation requires a later message to start a separate turn instead of steering the current one. A cold-resume Task creates its activation-owned `AbortController` before descriptor lookup or any provider await; the lookup, direct-parent authorization, and descriptor fold run inside the Task producer, so the same signal covers them and their failures settle that Task as `failed`. A persistence call that has no signal need not stop its underlying I/O, but the control service rechecks cancellation after every such await and cannot begin or publish child work afterward. Before Agent publication, abort makes the provider reject only after its creation transaction has rolled back and reached quiescence. After publication, the provider closes the creation-signal handoff race, attaches the same signal to the live run before returning it, and cancellation stops the child turn. `task_kill` and exact-owner disposal use this path even when provider resume has not returned a `SubagentRun`; Task settlement waits for rollback or run disposal and records `killed` only after the activation is quiescent. @@ -57,7 +57,7 @@ The control service does not serialize two callers that race a stopped child thr ### Model-facing `send_message` -The model receives one `send_message(subagent_id, message)` tool backed by `SubagentControlService.sendMessage()`. The control operation owns steer-or-resume orchestration and is distinct from the run's `SubagentRun.steer?()`, which only delivers to an already active run. The tool performs no lifecycle routing of its own. It attributes the follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the control service requires a caller-supplied `MessageSource` and carries it through both live steering and cold resume. The child model still receives ordinary user-role content, while the durable source prevents model-generated follow-ups from being classified as direct human input. A human adapter instead supplies `{ kind: 'user' }`. The tool lives in the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package so provider-bound `@deepseek-ai/dsh-tool-subagent` instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools. +The model receives one `send_message(subagent_id, message)` tool backed by `SubagentControlService.sendMessage()`. The control operation owns steer-or-resume orchestration and is distinct from the run's `SubagentRun.steer?()`, which only delivers to an already active run. The tool performs no lifecycle routing of its own. It attributes the follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }` and forwards its execution signal; the control service requires both the caller-supplied `MessageSource` and cancellation signal. The source crosses both live steering and cold resume, while cancellation owns only a pending live-delivery wait because a cold-resume Task returns immediately and owns its later cancellation. The child model still receives ordinary user-role content, while the durable source prevents model-generated follow-ups from being classified as direct human input. A human adapter instead supplies `{ kind: 'user' }` and its interaction signal. The tool lives in the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package so provider-bound `@deepseek-ai/dsh-tool-subagent` instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools. - If the child has a running Task and live-steering capability, the service calls `run.steer(message, source)` and returns the existing Task id; it creates no Task of its own. - If the child has no running Task, `send_message` creates a fresh Task, cold-resumes the durable session with the message, and returns the new Task id. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 1864f6d47f..019b623d44 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -39,7 +39,7 @@ durable child Session 如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 控制插件对的同时,也会挂载 `@deepseek-ai/dsh-tasks` 与 `@deepseek-ai/dsh-tool-tasks`。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 -取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 +取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。`sendMessage()` 要求调用方提供信号;若在线 steering 正在等待请求准入时该信号被中止,激活自有的 controller 会被中止,以便提供方丢弃待处理消息,并且该调用仅在子 agent 完全停稳后结算。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 从持久化存储恢复的 Task 会在查找描述符或等待任何提供方操作之前,创建由本次激活持有的 `AbortController`;描述符查找、直接 parent 鉴权和描述符归并都在该 Task producer 内部执行,因此同一信号覆盖它们,其失败会将该 Task 结算为 `failed`。对于不接受信号的持久化调用,可以让底层 I/O 执行完毕;但控制服务必须在每次这类 await 返回后重新检查取消状态,如已取消,之后不得开始或发布任何 child 工作。在 Agent 发布前收到中止信号时,提供方必须先回滚其创建事务并达到完全停稳状态,然后才让恢复调用以拒绝结束。Agent 发布后,提供方必须消除创建期间移交取消信号时的竞态,在返回前将同一信号附加到存活 run;之后取消会停止 child 轮次。即使提供方的恢复调用尚未返回 `SubagentRun`,`task_kill` 与对确切 owner 实例的 dispose 仍通过这条路径生效。Task 结算会等待回滚或 run dispose 完成,只有在激活完全停稳后才记录 `killed`。 @@ -57,7 +57,7 @@ durable child Session ### 面向模型的 `send_message` -模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具将后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;控制服务要求调用方提供 `MessageSource`,并在在线 steering 与 cold resume 两条路径中传递该来源。child 模型收到的仍是普通的 user role 内容,而持久化的来源信息可防止模型生成的后续消息被归类为直接用户输入。用户适配器则提供 `{ kind: 'user' }`。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 +模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具将后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`,并转发其执行信号;控制服务要求调用方同时提供 `MessageSource` 和取消信号。来源会贯穿在线 steering 和 cold resume 两条路径,而取消只控制尚未完成的在线投递等待,因为 cold resume Task 会立即返回,并自行负责后续取消。child 模型收到的仍是普通的 user role 内容,而持久化的来源信息可防止模型生成的后续消息被归类为直接用户输入。用户适配器则提供 `{ kind: 'user' }` 及其交互信号。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 - 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message, source)` 并返回现有 task id;它不会创建新 Task。 - 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 9cb67d757d..89a4dd5dfa 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1978,9 +1978,11 @@ startContinuable(spec: ContinuableStartSpec): ContinuableStart * @param childId - durable child session id. * @param message - user-role content to deliver. * @param source - durable caller attribution. + * @param signal - caller cancellation; while live delivery awaits admission, + * abort cancels the shared activation so the wait reaches quiescence. * @returns the existing steered Task or newly started Task. */ -sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise +sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, signal: AbortSignal, ): Promise /** * Register a provider under its name. Registration is effect-scoped and HMR diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index af4b1decda..aaa02e1587 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -subagent.md: 2497dbab9cfc8304eb7aaeba7109404ac614bbff -subagent.zh.md: 2d96e9bc635951746e72ed58a7c3638dc2598cc2 +# pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md +subagent.md: 1321429ac9e6280878016601646dd08981ab2b40 +subagent.zh.md: 072b2d2c1635d7c2c59b5a24d2bafc6ee32f8422 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 9864df25bd..1321429ac9 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -105,7 +105,7 @@ interface SubagentStartRequest { ## Continuable children: `SubagentContinuation` and `SubagentResumeRequest` -A **continuable background subagent** is a durable child session with a series of Task-backed activations. `SubagentService.startContinuable()` allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the resolved start request; the provider publishes exactly that id and appends the descriptor inside the child's first turn. `SubagentService.sendMessage()` loads and authorizes a stopped child before dispatching a fully resolved resume request through the raw `resume()` operation, or steers its live activation. An internal manager owns descriptor lookup and Task association only while `ctx.tasks` and `ctx.agents` exist; persistence is required per continuation operation, not to load the provider registry. `startContinuable()` returns both identities, while `sendMessage()` reports whether the message `steered` the existing Task or `started` a fresh one. Every sender supplies a `MessageSource`; the optional model-facing tool uses `CoordinatorMessageSource`, while a human adapter uses `{ kind: 'user' }`. +A **continuable background subagent** is a durable child session with a series of Task-backed activations. `SubagentService.startContinuable()` allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the resolved start request; the provider publishes exactly that id and appends the descriptor before the initial prompt is admitted. `SubagentService.sendMessage()` loads and authorizes a stopped child before dispatching a fully resolved resume request through the raw `resume()` operation, or steers its live activation. An internal manager owns descriptor lookup and Task association only while `ctx.tasks` and `ctx.agents` exist; persistence is required per continuation operation, not to load the provider registry. `startContinuable()` returns both identities, while `sendMessage()` reports whether the message `steered` the existing Task or `started` a fresh one. Every sender supplies a `MessageSource` and cancellation signal; abort while live delivery awaits admission cancels the shared activation and rejects after quiescence. The optional model-facing tool uses `CoordinatorMessageSource` and its tool-execution signal, while a human adapter uses `{ kind: 'user' }` and its interaction signal. ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 2d96e9bc63..072b2d2c16 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,13 +4,13 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方是 [dsh-tool-subagent](../../packages/subagent/tool-subagent)。提案与设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`)。同一个 `ctx.subagents` 服务通过由 Task 支撑的内部管理器负责可继续子 agent 编排。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续后台 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 -源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) +源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) ## 两类能力,两种发现方式 -提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性(steering(中途引导)、恢复)则是 [`SubagentRun`](#a-live-run-subagentrun) 上的可选方法——方法的存在即为能力,TypeScript 的类型收窄即为发现机制。 +提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性则是可选方法;方法存在即为能力,TypeScript 的类型收窄即为发现机制:提供确认语义的在线 steering(中途引导)是 [`SubagentRun.steer`](#a-live-run-subagentrun),从持久化存储恢复是 [`SubagentProvider.resume`](#the-provider-seam-subagentprovider)。 ```ts type-equiv /** @@ -18,9 +18,10 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [ba * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent * degradation" rule). These static flags cover features needed before a run exists; runtime - * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence - * is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option: - * `depthLimit` to `maxDepth`; the other names match. + * capabilities are optional methods whose presence is the capability — confirmed live steering + * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each + * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to + * `maxDepth`; the other names match. */ interface SubagentCapabilities { readonly outputSchema: boolean @@ -88,11 +89,82 @@ interface SubagentStartRequest { * persona (strict `{{…}}` interpolation against the registered variables). */ readonly persona?: string + /** + * Continuable-child intent, resolved by `ctx.subagents` before start. + * The provider MUST publish exactly `sessionId` as the child identity + * instead of allocating one internally, and MUST append the snapshotted + * `descriptor` as the child's turn-enclosed `subagent/descriptor` event + * before its first request. Requires {@link SubagentProvider.resume} (the + * continuation capability); the service rejects the request otherwise. + */ + readonly continuation?: SubagentContinuation } ``` `signal` 是就绪前后唯一的取消通道。[subagent 组合控制 Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)规定 persona、live 全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 +## 可继续子 agent:`SubagentContinuation` 与 `SubagentResumeRequest` + +**可继续后台 subagent** 是一份持久化子 agent 会话,由一系列由 Task 支撑的激活组成。`SubagentService.startContinuable()` 会分配稳定的子 agent id、对版本化的 `subagent/descriptor` payload 建立快照,并通过已解析的启动请求传入二者;提供方会准确发布该 id,并在初始 prompt 获准前追加描述符。`SubagentService.sendMessage()` 会先加载并授权已停止的子 agent,再通过底层 `resume()` 操作分发完全解析的恢复请求,或引导其实时激活。只有 `ctx.tasks` 和 `ctx.agents` 存在时,内部管理器才会负责描述符查找与 Task 关联;每项继续执行操作都要求持久化,而加载提供方注册表不要求持久化。`startContinuable()` 返回两个标识,`sendMessage()` 则报告消息是对现有 Task 执行了 `steered`,还是 `started` 一个新 Task。每个发送方都会提供 `MessageSource` 和取消信号;若在在线投递等待准入期间中止该信号,则会取消共享激活,并在其完全停稳后拒绝调用。可选的面向模型工具使用 `CoordinatorMessageSource` 及其工具执行信号,人工适配器则使用 `{ kind: 'user' }` 及其交互信号。 + +```ts type-equiv +/** Attribution for a model coordinator's follow-up to one of its children. */ +interface CoordinatorMessageSource { + readonly kind: 'coordinator' + /** Session id of the agent whose tool call produced the follow-up. */ + readonly senderSessionId: SessionId +} +``` + +```ts type-equiv +/** + * The resolved continuable-child identity and durable composition record a + * continuation caller attaches to a start request. + */ +interface SubagentContinuation { + /** Service-allocated stable child session id, published verbatim. */ + readonly sessionId: SessionId + /** Snapshotted descriptor persisted in the child log for cold resume. */ + readonly descriptor: SubagentDescriptorData +} +``` + +```ts type-equiv +/** + * What a caller asks for when resuming a persisted continuable child. The + * continuation manager loads the child log, folds and authorizes its descriptor, + * and passes this fully resolved request to + * {@link SubagentService.resume}, which dispatches to + * {@link SubagentProvider.resume}. The provider reconstructs the declared + * composition under the live parent's scope and drives one turn with `prompt`. + */ +interface SubagentResumeRequest { + /** The persisted child session id to resume. */ + readonly sessionId: SessionId + /** The follow-up message that starts the resumed activation's turn. */ + readonly prompt: ContentBlock[] + /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ + readonly source: MessageSource + /** + * The live parent agent — the direct parent recorded in the persisted child + * header. In-process backends reconstruct the child under this agent's + * currently loaded scope. + */ + readonly parent: Agent + /** + * Activation-owned cancellation signal, created before descriptor lookup. + * Same pre/post-publication contract as {@link SubagentStartRequest.signal}: + * an abort before publication rejects after rollback quiescence, and an + * abort afterward cancels the published child turn. + */ + readonly signal: AbortSignal + /** The folded durable descriptor whose composition the provider reconstructs. */ + readonly descriptor: SubagentDescriptorData +} +``` + +描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照,包括提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;它绝不会对可通过合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则必须明确更改版本。描述符省略 `subagentDepth`(从持久化存储恢复时,以持久化 header 中的 `delegationDepth` 为单调下界)和 `outputSchema`(单次激活的结果契约,而非持久化组合配置)。`subagent/descriptor` 事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。 + ## 终态结果:`SubagentResult` 一次 run 的最终产出,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到它,当子 agent 失败或结束时未产出有效 capture 时,提供方可能返回 `stopReason: 'error'`。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整——消费方将其映射为 `isError` 的工具结果,而非将部分输出报告为成功。 @@ -144,7 +216,7 @@ interface SubagentStopReasonMap { ## 活跃 run:`SubagentRun` -`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄。消费方 await `result` 并始终 dispose(资源释放)该 run,直至其完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可选的 `sendMessage` 和 `resume` 方法通过自身的存在来公布运行时能力。 +`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄;它表示一次可 dispose(资源释放)的激活,绝不是持久化子 agent handle。消费方 await `result` 并始终 dispose 该 run,直至其完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可继续结果为 completed 还表示提供方已确认本次激活的最终状态具备持久性;必需检查点失败则会 reject。可选且提供确认语义的 `steer` 方法通过自身的存在公布在线投递功能,并且只有在请求快照准入该消息后才会兑现。从持久化存储恢复属于提供方级操作:`SubagentProvider.resume` 会根据子 agent 的持久化会话重建一个新 run,因为进程内 run 在 dispose 或进程重启后就不再存在。 ```ts type-equiv /** @@ -169,8 +241,10 @@ interface SubagentRun { * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport * failure resolves with `stopReason: 'error'` so the consumer maps it to an - * `isError` tool result. Rejects only on an infrastructure fault the seam - * cannot represent as a stop reason. + * `isError` tool result. For a continuable activation, a completed result + * also means the provider confirmed the activation's final state durable. + * Rejects on an infrastructure fault the seam cannot represent as a stop + * reason, including a failed required durability checkpoint. */ readonly result: Promise /** @@ -179,15 +253,16 @@ interface SubagentRun { */ dispose(): Promise /** - * OPTIONAL (steering capability): send additional content to the running - * child between steps. Present only on providers that support live steering. + * OPTIONAL (confirmed live-steering capability): submit additional content + * to the active child and fulfill only after a committed request snapshot + * admits it. Rejects when terminal policy, cancellation, disposal, or a lost + * settlement race prevents admission; it never falls through to a queued + * untracked turn or cold resume. A run represents one disposable activation, + * so resuming a settled child goes through {@link SubagentProvider.resume}. + * `source` is retained on the admitted steering message without changing its + * user role in model history. */ - sendMessage?(content: ContentBlock[]): void - /** - * OPTIONAL (resume capability): send a follow-up task to a settled child, - * continuing its session, and return a fresh run for the continuation. - */ - resume?(content: ContentBlock[]): Promise + steer?(content: ContentBlock[], source: MessageSource): Promise } ``` @@ -223,10 +298,21 @@ interface SubagentProvider { * promise rejects. Ownership transfers to the caller only on fulfillment. */ start(request: SubagentStartRequest): Promise + /** + * OPTIONAL (continuation capability): reconstruct a persisted continuable + * child from its own transcript and declared descriptor, drive one + * follow-up turn, and return a fresh run. Method presence is the capability + * — the service rejects `resume` dispatch and continuable starts on + * providers without it. Same publication contract as {@link start}: if + * reconstruction fails or `request.signal` aborts before fulfillment, the + * provider rolls its creation transaction back to quiescence before + * rejecting; after fulfillment the same signal cancels the published run. + */ + resume?(request: SubagentResumeRequest): Promise } ``` -`start()` 仅在 run 就绪时 fulfill。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 +`start()` 仅在 run 就绪时 fulfill;`resume()` 采用相同的发布与生命周期观察契约。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 ## 进程内后端:深度与种子 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 80fb096b4f..624e7aab14 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -893,8 +893,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Start one durable continuable child through a Task-backed initial\n * activation.\n * @param spec - provider, Task label, and delegation request.\n * @returns the stable child id and initial activation Task id.\n */', }, { - signature: 'sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise', - jsDoc: '/**\n * Deliver a message to a continuable child by steering its live activation\n * or cold-resuming a fresh Task-backed activation.\n * @param parent - live direct parent authorizing the operation.\n * @param childId - durable child session id.\n * @param message - user-role content to deliver.\n * @param source - durable caller attribution.\n * @returns the existing steered Task or newly started Task.\n */', + signature: 'sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, signal: AbortSignal, ): Promise', + jsDoc: '/**\n * Deliver a message to a continuable child by steering its live activation\n * or cold-resuming a fresh Task-backed activation.\n * @param parent - live direct parent authorizing the operation.\n * @param childId - durable child session id.\n * @param message - user-role content to deliver.\n * @param source - durable caller attribution.\n * @param signal - caller cancellation; while live delivery awaits admission,\n * abort cancels the shared activation so the wait reaches quiescence.\n * @returns the existing steered Task or newly started Task.\n */', }, { signature: 'registerProvider(provider: SubagentProvider): () => void', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index e2e1b999ab..7ae82932d2 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 3d5d5e7498b1700c07486cc6e894e72fed681bec -README.zh.md: eb26c79665d387a1e779050dad476d8672f67642 +README.md: c0811eb3bd76543b4a07e7242772e38bd51db67e +README.zh.md: 6d346ee423af8e242c58486164cdef85d241b53d diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index af8a2f7b71..c0811eb3bd 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -31,7 +31,7 @@ Multiple providers may coexist under different names. This lets a deployment exp | `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. | | `resume(name, request)` | Capability-checked raw dispatch to `provider.resume?()` with the same run lifecycle observation as `start`; the caller owns descriptor lookup, authorization, and collection. | | `startContinuable(spec)` | Allocate a durable child id and register its initial Task-backed activation. Requires `ctx.tasks`, `ctx.agents`, session persistence, and a resumable provider. | -| `sendMessage(parent, childId, message, source)` | Steer the current activation or start a new Task that cold-resumes the durable child. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. | +| `sendMessage(parent, childId, message, source, signal)` | Steer the current activation or start a new Task that cold-resumes the durable child. Aborting `signal` while live delivery awaits admission cancels the shared activation and rejects after it reaches quiescence. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. | `SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, set a child persona, or carry a resolved `continuation` (the control-allocated stable child id plus its durable descriptor), which requires the provider's `resume` capability. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index e202718f81..6d346ee423 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -2,34 +2,38 @@ [English](README.md) | 中文 -subagent seam 允许一个 agent(智能体)通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API(`ctx.subagents`);提供方决定子 agent 在当前进程中、另一进程中,还是通过未来的传输机制运行。 +subagent seam 允许一个 agent(智能体)通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API(`ctx.subagents`);提供方决定子 agent 在当前进程、另一进程还是未来的传输之上运行。 -## 包(package)的角色 +## 包角色 -该系列包把稳定接口与实现、面向模型的工具分开: +该能力族把稳定接口与实现、面向模型的工具分开: | 包 | 角色 | |---|---| -| `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果类型和生命周期事件。 | -| `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent。 | -| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent。 | -| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent。 | -| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的工具。 | +| `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果/描述符类型、生命周期事件和可继续子 agent 编排。 | +| `@deepseek-ai/dsh-subagent-spawn` | 支持从持久化存储恢复的全新进程内子 agent。 | +| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容,并支持从持久化存储恢复的进程内子 agent。 | +| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 | +| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 | +| `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 | 多个提供方可以使用不同名称共存。因此,部署可以同时公开低成本的进程内子 agent 和隔离的 ACP 子 agent,而无需改变服务契约。 ## 服务 API -`SubagentService` 有四个主要操作: +`SubagentService` 有七个主要操作: | 成员 | 含义 | |---|---| -| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会明确报错。 | +| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 | | `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 | | `list()` | 按插入顺序返回提供方名称。 | -| `start(name, request)` | 校验请求的能力和语义值,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理启动过程中取得的全部资源。 | +| `start(name, request)` | 校验请求的能力和语义值,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。 | +| `resume(name, request)` | 在功能检查后将底层请求分发给 `provider.resume?()`,并沿用与 `start` 相同的运行生命周期观察;描述符查找、授权与收集由调用方负责。 | +| `startContinuable(spec)` | 分配持久化子 agent id,并注册其初始的由 Task 支撑的激活。要求 `ctx.tasks`、`ctx.agents`、会话持久化及可恢复的提供方。 | +| `sendMessage(parent, childId, message, source, signal)` | 引导当前激活,或启动新 Task 从持久化存储恢复子 agent。若在在线投递等待准入期间中止 `signal`,则会取消共享激活,并在其完全停稳后拒绝该调用。要求 `ctx.tasks` 和 `ctx.agents`;从持久化存储恢复还要求会话持久化。 | -`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消正在运行的子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。 +`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具、设置子 agent persona,或携带已解析的 `continuation`(由控制层分配的稳定子 agent id 及其持久化描述符);后者要求提供方具备 `resume` 功能。 同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 @@ -42,7 +46,11 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 -运行时功能通过可选方法是否存在来检查能力:`SubagentRun.steer?` 只有在活跃子 agent 的请求 snapshot 接纳消息后才会兑现,并会拒绝而非排队一个未跟踪轮次;`SubagentProvider.resume?` 则重建已持久化且可继续的子 agent。一次运行表示一个可 dispose(资源释放)的 activation,因此刻意不提供冷恢复操作;已释放的运行无法在重启后重建。 +运行时功能以可选方法表示,方法是否存在就是功能检查:`SubagentRun.steer?` 只有在活跃子 agent 的请求快照准入消息后才会兑现;无法准入时会拒绝,而不会把消息排入未受跟踪的轮次。`SubagentProvider.resume?` 则会重建持久化的可继续子 agent。run 表示一次可 dispose 的激活,因此有意不提供从持久化存储恢复操作;进程重启后无法重建已 dispose 的 run。 + +## 持久化描述符 + +该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在任何 Task 存在之前校验并分离声明的组合配置,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。格式错误的当前版本 payload 会在提供方分发前失败;不受支持的版本会使子 agent 无法恢复。payload 记录提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。它省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次激活的结果契约)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。 ## 委派深度 @@ -52,13 +60,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 所有权与生命周期 -`provider.start(request): Promise` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使启动过程中已取得的资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。 +`provider.start(request): Promise` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。`provider.resume?(request)` 对恢复后的激活采用相同契约。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。 +`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。对于可继续激活,完成的结果还会确认提供方已使其最终状态具备持久性;必需检查点失败会作为基础设施故障拒绝,而不会发布未经确认的输出。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。 -本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开该子 agent 本身,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。 +本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。可继续启动会准确发布由服务分配的 `continuation.sessionId`。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。 -服务只会发出 `subagent/start`,而且是在 `start()` 兑现后。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 +服务只会在 `start()` 或 `resume()` 兑现后发出 `subagent/start`。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 @@ -66,17 +74,17 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再对运行执行 dispose(资源释放),然后才返回。后台委派不会改变该 seam;消费方把启动过程和最终运行注册到通用 `ctx.tasks` 运行时,随后使用共享任务工具进行收集和取消。完整契约见[后台 subagent 任务 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task。可继续后台委派会调用 `ctx.subagents.startContinuable()`;只有 `ctx.tasks` 和 `ctx.agents` 可用时,其内部管理器才会存在,而会话持久化按每项继续执行操作解析。收集和取消使用共享 Task 工具。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 ## 模型体验 -通过 `dsh-tool-subagent` 间接产生影响;它渲染提供方特定的 schema,以及前台或通用后台结果,同时子 agent 工作上下文只留在子 agent 中。 +通过 `dsh-tool-subagent` 和 `dsh-tool-subagent-control` 间接产生影响;它们渲染提供方特定的 schema,以及前台、后台或后续操作结果,同时子 agent 工作上下文只留在子 agent 中。 #### KV Cache 影响 -不会直接使缓存失效;具名消费方负责请求前缀的任何变化。 +不会直接使缓存失效;具名消费方共同负责请求前缀的任何变化。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **运行时 steering 和延续只是 seam 能力**:当前工具中没有消费 `sendMessage` 和 `resume` 的面向模型消费方。 +- **ACP 子 agent 仍为一次性**:`AcpProvider.resume` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过提供方方法是否存在来确定。 - **生命周期事件只供观察**:影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 15bb14bbe4..cd3f7f1f76 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -248,6 +248,8 @@ export class SubagentContinuationManager { * @param childId - the stable child session id. * @param message - the user-role content to deliver. * @param source - caller-supplied attribution retained across either route. + * @param signal - caller cancellation. During live delivery, abort cancels + * the shared activation and rejects only after it reaches quiescence. * @returns whether the message `steered` the existing Task or `started` a new one. */ async sendMessage( @@ -255,13 +257,14 @@ export class SubagentContinuationManager { childId: SessionId, message: ContentBlock[], source: MessageSource, + signal: AbortSignal, ): Promise { this.assertOwnership(childId) const activation = this.activations.get(childId) if (activation !== undefined) { return { route: 'steered', - taskId: await this.steerActivation(activation, parent, childId, message, source), + taskId: await this.steerActivation(activation, parent, childId, message, source, signal), } } return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) } @@ -298,6 +301,7 @@ export class SubagentContinuationManager { childId: SessionId, message: ContentBlock[], source: MessageSource, + signal: AbortSignal, ): Promise { const taskId = activation.taskId /* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */ @@ -323,9 +327,23 @@ export class SubagentContinuationManager { 'NOT_DELIVERED', ) } + const cancelActivation = (): void => { + activation.controller.abort(signal.reason) + } + signal.addEventListener('abort', cancelActivation, { once: true }) + if (signal.aborted) { + cancelActivation() + signal.removeEventListener('abort', cancelActivation) + return await this.cancelledLiveDelivery(activation, childId) + } try { await run.steer(message, source) } catch (error: unknown) { + try { + signal.throwIfAborted() + } catch { + return await this.cancelledLiveDelivery(activation, childId, error) + } // Confirmed steering lost the race with request admission. Deliberately no // cold-resume fallback here: that would attach the message to a turn the // caller did not observe. @@ -334,10 +352,30 @@ export class SubagentContinuationManager { 'NOT_DELIVERED', { cause: error }, ) + } finally { + signal.removeEventListener('abort', cancelActivation) } return taskId } + /** Reject a cancelled live delivery only after its shared activation is quiescent. */ + private async cancelledLiveDelivery( + activation: ActiveActivation, + childId: SessionId, + cause?: unknown, + ): Promise { + /* v8 ignore if -- a published run implies the producer assigned `done` before its provider await resolved. */ + if (activation.done === undefined) { + throw new Error('published subagent activation has no settlement promise') + } + await activation.done + throw new SubagentError( + `subagent "${childId}" live delivery was cancelled; the message was not delivered`, + 'CANCELLED', + cause === undefined ? undefined : { cause }, + ) + } + /** * Cold-resume a persisted child into a fresh Task-backed activation. The * Task owns its `AbortController` before descriptor lookup: the load, diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 8809e6d4ea..902f9bcaea 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -228,6 +228,8 @@ export class SubagentService extends Service { * @param childId - durable child session id. * @param message - user-role content to deliver. * @param source - durable caller attribution. + * @param signal - caller cancellation; while live delivery awaits admission, + * abort cancels the shared activation so the wait reaches quiescence. * @returns the existing steered Task or newly started Task. */ sendMessage( @@ -235,8 +237,9 @@ export class SubagentService extends Service { childId: SessionId, message: ContentBlock[], source: MessageSource, + signal: AbortSignal, ): Promise { - return this.requireContinuations().sendMessage(parent, childId, message, source) + return this.requireContinuations().sendMessage(parent, childId, message, source, signal) } /** diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 1ef761881d..7cf2ca91b8 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -119,14 +119,16 @@ const coordinatorSource = { kind: 'coordinator', senderSessionId: SessionId('parent'), } as const +const testSendSignal = new AbortController().signal function sendMessage( ctx: Context, parent: Agent, childId: SessionId, content: ReturnType, + signal: AbortSignal = testSendSignal, ) { - return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' }) + return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' }, signal) } describe('SubagentService.startContinuable', () => { @@ -422,6 +424,7 @@ describe('SubagentService.sendMessage', () => { started.childId, message('also consider Y'), coordinatorSource, + testSendSignal, ) releaseFirst() const delivered = await delivery @@ -440,6 +443,27 @@ describe('SubagentService.sendMessage', () => { expect(steering?.data.message.source).toEqual(coordinatorSource) }) + it('cancels the active Task without enqueueing when live delivery is already aborted', async () => { + const { ctx, parent, adapter } = await setup(['hang']) + const started = ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const controller = new AbortController() + controller.abort('caller already cancelled') + + await expect(sendMessage( + ctx, + parent, + started.childId, + message('must not enqueue'), + controller.signal, + )).rejects.toMatchObject({ code: 'CANCELLED' }) + expect(ctx.agents.get(started.childId)).toBeUndefined() + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('killed') + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) + }) + it('rejects before acknowledgement when terminal policy prevents steering admission', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', 'structured_output', { answer: 7 }), @@ -473,6 +497,7 @@ describe('SubagentService.sendMessage', () => { started.childId, message('follow-up that terminal policy rejects'), coordinatorSource, + testSendSignal, ) releaseTool.resolve(undefined) await expect(delivery).rejects.toThrow(/message was not delivered/) @@ -495,6 +520,7 @@ describe('SubagentService.sendMessage', () => { started.childId, message('and then?'), coordinatorSource, + testSendSignal, ) expect(followUp.route).toBe('started') expect(followUp.taskId).not.toBe(started.taskId) diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml new file mode 100644 index 0000000000..cf3afead31 --- /dev/null +++ b/packages/subagent/tool-subagent-control/README.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 packages/subagent/tool-subagent-control/README.md +README.md: 0f1eb7a966689d1540c47f41e2f3fa89d2011d2b +README.zh.md: bd140f93f7338a6b1f0e89a285b273080cc5d3cb diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index c308e11d99..0f1eb7a966 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -1,8 +1,10 @@ # @deepseek-ai/dsh-tool-subagent-control +English | [中文](README.zh.md) + The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work. -The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the subagent service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered. +The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the subagent service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool forwards its execution signal, so cancellation while live delivery awaits admission cancels the shared activation and settles only after the child reaches quiescence. The tool renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered. ## Model Experience diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md new file mode 100644 index 0000000000..bd140f93f7 --- /dev/null +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-tool-subagent-control + +[English](README.md) | 中文 + +可选的全局具名 `send_message` 工具:`ctx.subagents.sendMessage()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。 + +本工具不执行生命周期路由。它将每条后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;subagent 服务会保留该来源,并在向运行中激活的现有 Task 在线投递消息与创建新 Task、从持久化存储恢复子 agent 之间做出选择。本工具会转发其执行信号,因此,若在在线投递等待准入期间取消,则会取消共享激活,并仅在子 agent 完全停稳后结算。本工具会渲染实际采用的路由及相关 Task id。投递失败会变为出错的工具结果,并明确说明消息未送达。 + +## 模型体验 + +### 工具 schema + +#### 模型看到的内容 + +已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id` 和 `message`,说明投递或继续执行的语义,以及通过 `task_output` 收集结果的路径。 + +#### Token 影响 + +每个父级请求支付固定的 schema 成本。 + +#### KV Cache 影响 + +前缀保持稳定;schema 不会在运行时改变。 + +### 投递结果 + +#### 模型看到的内容 + +消息加入运行中的激活时返回 `message delivered to running task `;消息启动一次从持久化存储恢复的激活时返回 `message started task continuing subagent `。同步路由失败,包括所有权冲突、steering(中途引导)竞态失败和缺少在线投递功能,都会成为出错的结果,其消息说明该消息未送达。不存在激活时始终报告 `started`:查找在该 Task 内运行,因此未知、属于其他 parent 或缺少描述符的子 agent 会表现为已启动的 Task 结算为 `failed`(通过 `task_output` 读取),而不是出错的 `send_message` 结果。 + +#### Token 影响 + +每次调用产生一条简短确认消息;子 agent 的响应只会在通过 `task_output` 收集时进入父级历史(完成通知是状态行,绝不是响应)。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **已投递的消息没有独立结果**:其效果体现在当前 Task 的最终结果中;只有已启动的后续操作才拥有新的 Task 结果。 +- **投递可能在时序竞态中失败**:消息与 Task 结算、取消或清理发生竞态时会明确失败,不会改用从持久化存储恢复;模型会在 Task 结算后重试。 diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index d95ecb77a9..3e3bc8eff9 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -72,6 +72,7 @@ export function apply(ctx: Context): void { SessionId(args.subagent_id), message, { kind: 'coordinator', senderSessionId: parent.id }, + exec.signal, ) return Promise.resolve(result) }, diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 02c7e6c1a3..c91c657bac 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -34,9 +34,10 @@ async function setup(script: ConstructorParameters[0]) { await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) await ctx.plugin(tool) - ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const adapter = new MockAdapter(script) + ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - return { ctx, parent } + return { ctx, parent, adapter } } function text(result: { content: { type: string; text?: string }[] }): string { @@ -44,9 +45,15 @@ function text(result: { content: { type: string; text?: string }[] }): string { } let calls = 0 -function callTool(ctx: Context, name: string, args: unknown, agent?: unknown) { +function callTool( + ctx: Context, + name: string, + args: unknown, + agent?: unknown, + signal: AbortSignal = testToolSignal, +) { return ctx.tools.execute({ - signal: testToolSignal, + signal, callId: CallId(`call-${++calls}`), name, arguments: args, @@ -114,6 +121,40 @@ describe('dsh-tool-subagent-control', () => { expect(text(result)).toBe('message delivered to running task subagent-9') }) + it('cancels a pending live-delivery wait when the tool signal aborts', async () => { + const { ctx, parent, adapter } = await setup(['hang']) + const started = ctx.subagents.startContinuable({ + provider: 'spawn', + label: 'hung work', + request: { prompt: [{ type: 'text', text: 'wait' }], parent }, + }) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const deliveryStarted: PromiseWithResolvers = Promise.withResolvers() + const sendMessage = ctx.subagents.sendMessage.bind(ctx.subagents) + ctx.subagents.sendMessage = (agent, childId, message, source, signal) => { + const delivery = sendMessage(agent, childId, message, source, signal) + deliveryStarted.resolve() + return delivery + } + + const controller = new AbortController() + const execution = callTool(ctx, 'send_message', { + subagent_id: started.childId, + message: 'follow up', + }, parent, controller.signal) + await deliveryStarted.promise + controller.abort('parent tool cancelled') + + const result = await execution + expect(result.isError).toBe(true) + expect(result.error?.info?.code).toBe('CANCELLED') + expect(ctx.agents.get(started.childId)).toBeUndefined() + const snapshot = await ctx.tasks.wait(started.taskId, 5_000, parent) + expect(snapshot.status).toBe('killed') + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) + }) + it('reports a delivery failure as an errored, not-delivered result', async () => { const { ctx, parent } = await setup([]) const result = await callTool(ctx, 'send_message', { From f14121a4c22af96eb9b15dc8a456e9a389ef6e47 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:10:24 +0800 Subject: [PATCH 046/114] refactor(subagent): narrow continuation interface --- ...07-12-agent-scope-runtime-design.i18n.yaml | 6 +- .../2026-07-12-agent-scope-runtime-design.md | 2 +- ...026-07-12-agent-scope-runtime-design.zh.md | 2 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 6 +- .../2026-06-21-subagent-capability-seam.md | 2 +- .../2026-06-21-subagent-capability-seam.zh.md | 2 +- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 44 +++--- ...-21-continuable-background-subagents.zh.md | 44 +++--- ...6-merge-subagent-control-service.i18n.yaml | 4 +- ...26-07-26-merge-subagent-control-service.md | 6 +- ...07-26-merge-subagent-control-service.zh.md | 6 +- ...subagent-continuation-operations.i18n.yaml | 6 + ...-named-subagent-continuation-operations.md | 36 +++++ ...med-subagent-continuation-operations.zh.md | 36 +++++ docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 16 +-- docs/cordis-catalog/services.md | 52 +++---- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 85 ++++++++---- docs/core-data-structures/subagent.zh.md | 85 ++++++++---- docs/event-producer-consumer.md | 10 +- packages/compact/compact-basic/src/index.ts | 4 +- .../tests/manual-compact.spec.ts | 6 +- .../cordis/tool-cordis/src/api-catalog.ts | 48 +++---- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 3 +- packages/core/session/README.zh.md | 3 +- packages/core/session/src/index.ts | 34 +---- packages/core/session/tests/scoped.spec.ts | 15 +-- .../session-checkpoint-policy/src/index.ts | 4 +- .../tests/coordinator-contract.ts | 4 +- .../tests/persistence.spec.ts | 6 +- packages/subagent/subagent-fork/src/index.ts | 11 +- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 15 ++- .../tests/subagent-inprocess.spec.ts | 19 ++- packages/subagent/subagent-spawn/src/index.ts | 11 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 13 +- packages/subagent/subagent/README.zh.md | 13 +- .../subagent/subagent/src/continuation.ts | 79 +++++++---- packages/subagent/subagent/src/index.ts | 83 ++++++------ packages/subagent/subagent/src/types.ts | 50 ++++--- .../subagent/tests/continuation.spec.ts | 127 ++++++++++++------ .../subagent/subagent/tests/service.spec.ts | 29 ++-- .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 2 +- .../tool-subagent-control/README.zh.md | 2 +- .../tool-subagent-control/src/index.ts | 14 +- .../tests/tool-subagent-control.spec.ts | 10 +- scripts/gen-cordis-catalog.ts | 6 +- scripts/type-equiv.manifest.json | 17 ++- 55 files changed, 669 insertions(+), 441 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md create mode 100644 .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index cbaedf3ab9..82b4452be3 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-12-agent-scope-runtime-design.md: 232fc02d66411b5ee8a21943795a3be4713bf238 -2026-07-12-agent-scope-runtime-design.zh.md: 39d558f8cde0183a3590d268aca36ea85e5f5c63 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +2026-07-12-agent-scope-runtime-design.md: a0a9a90bcac6e8f2ed0e06f3fbccb7b1244da278 +2026-07-12-agent-scope-runtime-design.zh.md: 09912162808f2f71d9ea49892cc61bbbeb1cf172 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 232fc02d66..a0a9a90bca 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -270,7 +270,7 @@ Subagent startup has one ownership transfer. The provider owns partial resources `SubagentStartRequest.signal` is required. Aborting it requests cancellation during startup and after readiness. `SubagentRun.dispose()` also requests cancellation and awaits quiescence. There is no separate public `run.cancel()` channel. -Optional `sendMessage()` supports a live backend that can accept steering. Optional `resume()` returns `Promise` because the resumed child has the same asynchronous readiness boundary. +Optional `SubagentRun.steer()` supports a live backend that can confirm steering admission. Optional `SubagentProvider.resume()` returns `Promise` because a reconstructed child has the same asynchronous readiness boundary. The service validates provider capabilities and request semantics before calling the provider. A provider rejection cleans any partial resources before the rejection escapes and emits no `subagent/start`/`subagent/end` pair. After fulfillment, the service attaches result observation, emits scoped start, and returns the run. Provider removal prevents later starts but does not revoke a run already accepted by the provider. diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index 39d558f8cd..0991216280 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -270,7 +270,7 @@ Subagent 启动有一次所有权转移。提供方拥有部分资源直到其 s `SubagentStartRequest.signal` 是必需的。中止它会在启动期间和就绪之后请求取消。`SubagentRun.dispose()` 也请求取消并等待完全停稳。没有单独的公开 `run.cancel()` 通道。 -可选的 `sendMessage()` 支持能接受 steering 的活跃后端。可选的 `resume()` 返回 `Promise`,因为恢复的子级有相同的异步就绪边界。 +可选的 `SubagentRun.steer()` 支持能够确认 steering 准入的活跃后端。可选的 `SubagentProvider.resume()` 返回 `Promise`,因为重建的子级有相同的异步就绪边界。 服务在调用提供方之前验证提供方能力和请求语义。提供方拒绝在拒绝逃出之前清理所有部分资源,且不发射 `subagent/start`/`subagent/end` 对。兑现之后,服务附加结果观察、发射作用域 start 并返回 run。提供方移除阻止后续 start,但不撤销提供方已接受的 run。 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 3d1140f5cf..39fa3f91b7 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-21-subagent-capability-seam.md: 9c17a93751de209e5e4e5a0ca7d7b1d8e5656a47 -2026-06-21-subagent-capability-seam.zh.md: 6294c84a8fa11e492316f4b69048aa5f477aa04f +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +2026-06-21-subagent-capability-seam.md: d47d8fea1b4c03b19891e6af2d5b1d933feeb553 +2026-06-21-subagent-capability-seam.zh.md: e2fce78d0f76892e03136ab374a116152717f848 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 9c17a93751..d47d8fea1b 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -43,7 +43,7 @@ A provider exposes `start(request) → Promise`. Fulfillment publis ### Two kinds of optional capability, discovered two ways - **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`, `persona`) ride on a static `provider.capabilities` descriptor. The service checks every requested one BEFORE delegating and **rejects loud** (`SubagentError('UNSUPPORTED_CAPABILITY')`) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods. -- **Runtime features** (steering via `sendMessage`, follow-up via `resume`) are **optional methods** on `SubagentRun`. The method's presence IS the capability, and TypeScript narrowing is the discovery mechanism: a consumer cannot call an absent method without narrowing first, so there is no silent-degradation path and no separate flags object to keep in sync. +- **Runtime features** are optional methods at their owning seams: confirmed live delivery is `SubagentRun.steer`, while persisted reconstruction is `SubagentProvider.resume`. Method presence is the capability and TypeScript narrowing is the discovery mechanism, so no separate flags object can drift from the implementation. ### Fork vs. fresh are separate backends, not a flag diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 6294c84a8f..e2fce78d0f 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -43,7 +43,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ### 两类可选能力,两种发现方式 - **启动时功能**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的功能,如果提供方不支持则**大声拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些功能必须在 run 存在之前检查,因此不能是运行时方法。 -- **运行时功能**(通过 `sendMessage` 进行 steering、通过 `resume` 进行后续对话)是 `SubagentRun` 上的**可选方法**。方法的存在本身即为能力,TypeScript 类型收窄即为发现机制:消费方不经收窄就无法调用不存在的方法,因此不存在静默降级路径,也不需要额外的 flags 对象来保持同步。 +- **运行时功能**是在其所属 seam 上定义的可选方法:提供确认语义的在线投递对应 `SubagentRun.steer`,持久化重建对应 `SubagentProvider.resume`。方法的存在本身即为能力,TypeScript 类型收窄即为发现机制,因此不需要可能与实现失同步的独立 flags 对象。 ### Fork 与 fresh 是独立后端,而非一个 flag diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 20623c2641..8813e2d10b 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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-07-21-continuable-background-subagents.md -2026-07-21-continuable-background-subagents.md: 4c2cc4ce2538a3d1cf6756168fe4dc1a6448d22a -2026-07-21-continuable-background-subagents.zh.md: 019b623d447781bf254cb241c1b8f1c64fd49c4a +2026-07-21-continuable-background-subagents.md: 0ea085a3eb9c6e661c1f009f338b264c06f14983 +2026-07-21-continuable-background-subagents.zh.md: 93f4c5b8ba4a052c5a6bb6eac3802601eb0797a5 diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index 4c2cc4ce25..0ea085a3eb 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-21-continuable-background-subagents.zh.md) -The service-placement and provider-capability policy in this record are superseded by [Merge subagent control into the subagent service](../simplification/2026-07-26-merge-subagent-control-service.md). The continuation, persistence, Task, routing, authorization, and durability semantics remain in force; references below to the control service describe the internal continuation manager now exposed through `ctx.subagents`. +The service-placement and provider-capability policy in this record are superseded by [Merge subagent control into the subagent service](../simplification/2026-07-26-merge-subagent-control-service.md) and [Intent-named subagent continuation operations](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md). The continuation, persistence, Task, routing, authorization, and durability semantics remain in force. ## Problem @@ -25,39 +25,39 @@ durable child Session activation 3: Task 3 -> SubagentRun -> AgentHandle -> dispose ``` -Foreground delegation keeps its one-shot behavior. Continuation covers background in-process spawn and fork children. A provider supports persisted cold resume before its children are advertised as continuable — `tool-subagent` branches its background route on the mounted provider's `resume` capability — and ACP children remain one-shot until the deferred ACP continuation work below is complete. +Foreground delegation keeps its one-shot behavior. Continuation covers background in-process spawn and fork children. Each `tool-subagent` instance selects `backgroundMode: 'one-shot' | 'continuable'`; configured continuable mode requires the mounted provider's `resume` capability, while a resumable provider may still use one-shot background policy. ACP children remain one-shot until the deferred ACP continuation work below is complete. -The low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic. It registers providers, validates and dispatches `start` or `resume`, observes run lifecycle, and returns holder-owned runs. The `SubagentControlService` (`ctx.subagentControl` in `@deepseek-ai/dsh-subagent-control`) owns stable continuable-child ids, descriptor persistence and lookup by known child id, Task-backed activation, and message routing. The provider-bound `@deepseek-ai/dsh-tool-subagent` plugin and human-facing adapters call that control service for continuable background work; foreground one-shot delegation still calls `ctx.subagents.start()` directly. The globally named model tool is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. Parent-to-child enumeration and `list_agents` belong to a separate durable-catalog proposal. +`ctx.subagents` is the only public service. Ordinary `start` stays collection-, Task-, and persistence-agnostic: it validates provider capabilities, dispatches one activation, observes run lifecycle, and returns a holder-owned run. An injected internal continuation manager owns stable child ids, descriptor persistence and lookup, Task-backed activation, and routing through `startContinuable` and `followup`; provider start and resume dispatch use private closures after the manager resolves continuation state. The provider-bound `@deepseek-ai/dsh-tool-subagent` plugin and human-facing adapters call the intent operations for continuable background work; foreground and one-shot background delegation use ordinary `start`. The globally named model tool is a thin optional adapter in `@deepseek-ai/dsh-tool-subagent-control`, and its presence does not decide whether continuable work starts. Parent-to-child enumeration and `list_agents` belong to a separate durable-catalog proposal. ### Task and cancellation ownership -The initial background delegation asks the control service to start the child and register its Task. A continuable provider confirms the activation's final session state durable before fulfilling a successful run result. Task settlement awaits that result, calls `run.dispose()` (through the control service's `settleRun`), and only then records the `TaskOutcome`; `task_kill` aborts the active run, whose settlement path still disposes it. A terminal Task therefore leaves the durable child session but no live child Agent. A required durability checkpoint with no installed listener or a failing listener rejects the run with stable code `DURABILITY_FAILED` and the checkpoint failure as its cause; the control service records a failed Task whose detail explains that the latest state was not confirmed persisted and may be unavailable or stale on resume. +The initial background delegation asks `ctx.subagents` to start the child and register its Task. A continuable provider confirms the activation's final session state durable before fulfilling a successful run result. Task settlement awaits that result, calls `run.dispose()` through the continuation manager's settlement path, and only then records the `TaskOutcome`; `task_kill` aborts the active run, whose settlement path still disposes it. A terminal Task therefore leaves the durable child session but no live child Agent. A required durability checkpoint with no installed listener or a failing listener rejects the run with stable code `DURABILITY_FAILED` and the checkpoint failure as its cause; the manager records a failed Task whose detail explains that the latest state was not confirmed persisted and may be unavailable or stale on resume. Every later turn creates another Task. Its producer resources cover only that activation, never the child session. It reaches one terminal status, has one result, and is never reopened. The exact live parent Agent remains the Task registry owner: disposing that instance cancels, awaits, and removes its Tasks. Task APIs authorize a caller whose session id matches that owner, but a same-id replacement does not become the notification or teardown target. This preserves the `settleRun()` contract and bounds Task-owned live children by concurrent work rather than historical session count. -Opening a child session in a human-facing adapter reads its persisted transcript and does not resume an Agent merely to display it. Human input starts or joins the same Task-backed activation used by parent input through the control service. A human-started Task retains the exact currently loaded parent Agent as its notification target, and `task_output` remains the single result path. The existing completion listener injects at most one unsolicited notice while the Task is unreported; `kill`, a terminal read, or a terminal wait may mark it reported and suppress that notice. Human interaction is therefore permitted only while that parent instance remains live. A user-owned conversation that may outlive the parent and explicitly merge a conclusion back belongs to [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md), not this Task-owned lifecycle. +Opening a child session in a human-facing adapter reads its persisted transcript and does not resume an Agent merely to display it. Human input starts or joins the same Task-backed activation used by parent input through the continuation manager. A human-started Task retains the exact currently loaded parent Agent as its notification target, and `task_output` remains the single result path. The existing completion listener injects at most one unsolicited notice while the Task is unreported; `kill`, a terminal read, or a terminal wait may mark it reported and suppress that notice. Human interaction is therefore permitted only while that parent instance remains live. A user-owned conversation that may outlive the parent and explicitly merge a conclusion back belongs to [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md), not this Task-owned lifecycle. -`TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. SDK-generated spawn and fork compositions mount `@deepseek-ai/dsh-tasks` and `@deepseek-ai/dsh-tool-tasks` with the subagent control pair. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations. +`TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. SDK-generated spawn and fork compositions mount `@deepseek-ai/dsh-tasks-local` and `@deepseek-ai/dsh-tool-tasks` with the subagent tools. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations. -Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as `killed`; the messages do not have independent results or cancellation rights. `sendMessage()` requires the caller's signal, and abort while live steering awaits request admission aborts the activation-owned controller so the provider discards the pending message and the call settles only after the child reaches quiescence. Independent cancellation requires a later message to start a separate turn instead of steering the current one. +Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as `killed`; the messages do not have independent results or cancellation rights. `followup()` requires the caller's signal, and abort while live steering awaits request admission aborts the activation-owned controller so the provider discards the pending message and the call settles only after the child reaches quiescence. Independent cancellation requires a later message to start a separate turn instead of steering the current one. -A cold-resume Task creates its activation-owned `AbortController` before descriptor lookup or any provider await; the lookup, direct-parent authorization, and descriptor fold run inside the Task producer, so the same signal covers them and their failures settle that Task as `failed`. A persistence call that has no signal need not stop its underlying I/O, but the control service rechecks cancellation after every such await and cannot begin or publish child work afterward. Before Agent publication, abort makes the provider reject only after its creation transaction has rolled back and reached quiescence. After publication, the provider closes the creation-signal handoff race, attaches the same signal to the live run before returning it, and cancellation stops the child turn. `task_kill` and exact-owner disposal use this path even when provider resume has not returned a `SubagentRun`; Task settlement waits for rollback or run disposal and records `killed` only after the activation is quiescent. +A cold-resume Task creates its activation-owned `AbortController` before descriptor lookup or any provider await; the lookup, direct-parent authorization, and descriptor fold run inside the Task producer, so the same signal covers them and their failures settle that Task as `failed`. A persistence call that has no signal need not stop its underlying I/O, but the continuation manager rechecks cancellation after every such await and cannot begin or publish child work afterward. Before Agent publication, abort makes the provider reject only after its creation transaction has rolled back and reached quiescence. After publication, the provider closes the creation-signal handoff race, attaches the same signal to the live run before returning it, and cancellation stops the child turn. `task_kill` and exact-owner disposal use this path even when provider resume has not returned a `SubagentRun`; Task settlement waits for rollback or run disposal and records `killed` only after the activation is quiescent. ### Active run association -The control service keeps a process-local association from child session id to its current Task and, after provider publication, its run. It installs the Task association before awaiting provider start or resume, fills in the returned run, and removes the association only after run disposal and Task terminal publication. This association exists only so parent and human senders can find the same activation; it is not a durable catalog, public `ManagedSubagent`, admission reservation, or run-state machine. +The continuation manager keeps a process-local association from child session id to its current Task and, after provider publication, its run. It installs the Task association before awaiting provider start or resume, fills in the returned run, and removes the association only after run disposal and Task terminal publication. This association exists only so parent and human senders can find the same activation; it is not a durable catalog, public `ManagedSubagent`, admission reservation, or run-state machine. -For a continuable initial activation, the control service allocates the stable child session id before Task creation and passes it in the resolved provider start request (`SubagentStartRequest.continuation`); in-process spawn and fork publish that exact id instead of allocating one internally. The background tool acknowledgement exposes both identities as `started subagent as task `. The child id names the durable conversation across activations, while the Task id names only the current activation. A failed initial Task or a process exit before the first child flush can leave an **unmaterialized child**: the caller holds a child id without a durable header and descriptor. Later by-id control operations report that id as unavailable (the started Task fails with that detail), and durable enumeration omits it. +For a continuable initial activation, the continuation manager allocates the stable child session id before Task creation and passes it as `SubagentProviderStartRequest.continuation`; in-process spawn and fork publish that exact id instead of allocating one internally. Ordinary `SubagentStartRequest` has no continuation field. The background tool returns canonical `{ kind: 'background', taskId, subagentId }`, rendered as `started subagent as task `. The child id names the durable conversation across activations, while the Task id names only the current activation. A failed initial Task or a process exit before the first child flush can leave an **unmaterialized child**: the caller holds a child id without a durable header and descriptor. Later by-id operations report that id as unavailable (the started Task fails with that detail), and durable enumeration omits it. -Every continuable child turn is admitted through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the control service synchronously compares its association with `ctx.agents.get(childId)`. A registry Agent with no association, or a registry Agent different from the associated `run.localAgent`, is an ownership conflict: the control service fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary. +Every continuable child turn is admitted through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the continuation manager synchronously compares its association with `ctx.agents.get(childId)`. A registry Agent with no association, or a registry Agent different from the associated `run.localAgent`, is an ownership conflict: the manager fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary. -Routing follows the Task association. A running Task accepts live delivery through the run's optional confirmed `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork first synchronously require the child to be `running` and reject an already committed structured capture, then call `Agent.steer()` and await that exact message's admission receipt. The default loop gives every steering item a message-owned receipt and resolves it `admitted` only after a successful pre-step has appended the message, captured the immutable request history, and committed `step/start`; terminal turn policy, cancellation, and disposal resolve pending receipts `rejected`. A non-terminal turn close may carry pending steering into a later queued turn without acknowledging it. Providers must check the live status before `Agent.steer()` so its idle path cannot start a turn outside the observed run. If Task settlement or terminal policy wins after association lookup but before request admission, `steer()` rejects, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. +Routing follows the Task association. A running Task accepts live delivery through the run's optional confirmed `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork first synchronously require the child to be `running` and reject an already committed structured capture, then call `Agent.steer()` and await that exact message's admission receipt. The default loop gives every steering item a message-owned receipt and resolves it `admitted` only after `agent/step` and asynchronous prompt assembly succeed, the message is appended, immutable request history is captured, and `step/start` commits; terminal turn policy, cancellation, and disposal resolve pending receipts `rejected`. A non-terminal turn close may carry pending steering into a later queued turn without acknowledging it. Providers must check the live status before `Agent.steer()` so its idle path cannot start a turn outside the observed run. If Task settlement or terminal policy wins after association lookup but before request admission, `steer()` rejects, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. -The control service does not serialize two callers that race a stopped child through paths outside it, nor does it model a separate settling phase between result production and disposal. The synchronous association install before the producer's first await admits one activation per child in this process — a competing `sendMessage` during resume load observes the pending activation and fails explicitly — while a bypassing publication still loses at the Agent registry's same-session collision boundary. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction. +The continuation manager does not serialize two callers that race a stopped child through paths outside it, nor does it model a separate settling phase between result production and disposal. The synchronous association install before the producer's first await admits one activation per child in this process — a competing `followup` during resume load observes the pending activation and fails explicitly — while a bypassing publication still loses at the Agent registry's same-session collision boundary. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction. ### Model-facing `send_message` -The model receives one `send_message(subagent_id, message)` tool backed by `SubagentControlService.sendMessage()`. The control operation owns steer-or-resume orchestration and is distinct from the run's `SubagentRun.steer?()`, which only delivers to an already active run. The tool performs no lifecycle routing of its own. It attributes the follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }` and forwards its execution signal; the control service requires both the caller-supplied `MessageSource` and cancellation signal. The source crosses both live steering and cold resume, while cancellation owns only a pending live-delivery wait because a cold-resume Task returns immediately and owns its later cancellation. The child model still receives ordinary user-role content, while the durable source prevents model-generated follow-ups from being classified as direct human input. A human adapter instead supplies `{ kind: 'user' }` and its interaction signal. The tool lives in the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package so provider-bound `@deepseek-ai/dsh-tool-subagent` instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools. +The model receives one `send_message(subagent_id, message)` tool backed by `SubagentService.followup()`, matching the intent verb on `Agent`. The service operation owns steer-or-resume orchestration and is distinct from the run's `SubagentRun.steer?()`, which only delivers to an already active run. The tool performs no lifecycle routing of its own. It attributes the follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }` and forwards `{ source, signal }`; the service requires both facts in one options object. The source crosses both live steering and cold resume, while cancellation owns only a pending live-delivery wait because a cold-resume Task returns immediately and owns its later cancellation. The child model still receives ordinary user-role content, while the durable source prevents model-generated follow-ups from being classified as direct human input. A human adapter instead supplies `{ kind: 'user' }` and its interaction signal. The tool lives in the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package so provider-bound `@deepseek-ai/dsh-tool-subagent` instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools. - If the child has a running Task and live-steering capability, the service calls `run.steer(message, source)` and returns the existing Task id; it creates no Task of its own. - If the child has no running Task, `send_message` creates a fresh Task, cold-resumes the durable session with the message, and returns the new Task id. @@ -67,19 +67,19 @@ The service result identifies the route as `steered` with the existing Task id o A delivered message has no independent result: its effect is reflected in the current Task's eventual result. A started follow-up has the fresh Task's result and existing `task_output` read path. The subagent layer adds no second completion injection. -Human input uses the same control operation. The UI may display the child transcript and current Task state, while cancellation calls the Task service with the loaded parent as caller. Tool schema and UI adapters are consumers of one control-service contract rather than separate execution paths. +Human input uses the same `followup` operation. The UI may display the child transcript and current Task state, while cancellation calls the Task service with the loaded parent as caller. Tool schema and UI adapters are consumers of one service contract rather than separate execution paths. ### Durable child handle and cold resume -The control service snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts)) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution — a prepended one-shot `agent/prompt-submit` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event before downstream prompt admission can block or throw. Allowed admission opens the initial child turn afterward; rejected admission leaves the descriptor as a pre-turn log-only fact, and the activation's final required checkpoint persists it. The event carries no `surfaceOp`, remains outside model history, and survives when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) and its header identifies the caller as the direct parent. +The continuation manager snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts)) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution — a prepended one-shot `agent/prompt-submit` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event before downstream prompt admission can block or throw. Allowed admission opens the initial child turn afterward; rejected admission leaves the descriptor as a pre-turn log-only fact, and the activation's final required checkpoint persists it. The event carries no `surfaceOp`, remains outside model history, and survives when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) and its header identifies the caller as the direct parent. The versioned descriptor (`SUBAGENT_DESCRIPTOR_VERSION` in [descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts)) contains the subagent provider name, resolved child `agentOptions.provider` and `agentOptions.model`, and optional `persona` and `toolFilter`. It does not snapshot the merge-extensible `AgentOptions` object: unrelated extension values cannot make continuation fail merely because they are not JSON. It deliberately omits `subagentDepth`; cold resume relies on the persisted header's `delegationDepth` rather than reconstructing depth from the descriptor. `outputSchema` belongs to one activation's result contract rather than durable child composition. The child header remains authoritative for the child id, `cwd`, `parentSession`, `seedLength`, and `delegationDepth`, while the persisted child transcript owns the fork seed and subsequent history. [`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) takes the maximum of header and runtime values, so reconstructed runtime options may deepen the persisted value but never lower it and a resumed child cannot regain a top-level delegation budget. -Cold resume cannot depend on an optional method of the old `SubagentRun`, because that run has been disposed and is not retained across process restart. `SubagentRun` has no `resume` operation: a run represents one disposable activation and exposes only activation-scoped operations. The former `SubagentRun.sendMessage?()` capability is named `SubagentRun.steer?()` so its confirmed live-only contract cannot be confused with service orchestration or the model-facing tool. +Cold resume cannot depend on an optional method of `SubagentRun`, because that run has been disposed and is not retained across process restart. A run represents one disposable activation and exposes only activation-scoped operations. `SubagentRun.steer?()` names the confirmed live-only capability so it cannot be confused with service orchestration or the model-facing tool. -`SubagentControlService`'s resume path loads the known child session, folds its descriptor, authorizes the persisted `parentSession`, and runs inside the Task it creates. It passes a fully resolved request, including the Task-owned cancellation signal, to the low-level `SubagentService.resume(provider, request)`, whose only responsibility is capability-checked provider dispatch and the ordinary run lifecycle observation used by `start`. The selected `SubagentProvider.resume?()` owns transport-specific reconstruction (in-process: `parent.ctx.agents.resume` under the currently loaded parent scope) and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag exists. `SubagentControlService.sendMessage()` chooses between the associated run's `steer?()` operation and this cold-resume path. Neither the low-level service nor a provider enumerates durable children or associates Tasks. +The internal continuation manager's resume path loads the known child session, folds its descriptor, authorizes the persisted `parentSession`, and runs inside the Task it creates. It passes a fully resolved `SubagentProviderResumeRequest`, including the Task-owned cancellation signal, through a private service closure whose only responsibility is capability-checked provider dispatch and the ordinary run lifecycle observation used by `start`. The selected `SubagentProvider.resume?()` owns transport-specific reconstruction (in-process: `parent.ctx.agents.resume` under the currently loaded parent scope) and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag exists. `SubagentService.followup()` chooses between the associated run's `steer?()` operation and this cold-resume path. Neither private provider dispatch nor a provider enumerates durable children or associates Tasks. -The background tool validates and snapshots descriptor inputs before calling `TaskService.start()`. A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. In-process continuable providers perform a final session flush after the child becomes idle and before reading the result; this retries a failed loop checkpoint while the child is still live. If the final confirmation fails, the provider rejects instead of returning unconfirmed output, the control service disposes the run, and the already-created Task settles as `failed` with the durability diagnosis in its detail. Cancellation during the confirmation owns the still-unpublished activation result, so a completed child turn or a later checkpoint failure cannot replace the Task's `killed` outcome. Foreground one-shot runs retain the loop's best-effort checkpoint behavior. In-process spawn and fork reconstruct composition under the currently loaded parent scope. A fork resume loads the child's own persisted transcript, which already contains the completed-turn prefix captured at initial creation; it never forks the parent's newer history again. Resuming a parent does not eagerly resume its children. +The background tool validates and snapshots descriptor inputs before calling `TaskService.start()`. A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. In-process continuable providers perform a final `SessionStore.flush()` after the child becomes idle and before reading the result; `true` confirms at least one durability listener participated, `false` is a required-checkpoint failure, and rejection carries a listener failure. This retries a failed loop checkpoint while the child is still live. If the final confirmation fails, the provider rejects instead of returning unconfirmed output, the continuation manager disposes the run, and the already-created Task settles as `failed` with the durability diagnosis in its detail. Cancellation during the confirmation owns the still-unpublished activation result, so a completed child turn or a later checkpoint failure cannot replace the Task's `killed` outcome. Foreground one-shot runs retain the loop's best-effort checkpoint behavior. In-process spawn and fork reconstruct composition under the currently loaded parent scope. A fork resume loads the child's own persisted transcript, which already contains the completed-turn prefix captured at initial creation; it never forks the parent's newer history again. Resuming a parent does not eagerly resume its children. TODO (ACP continuation): persist the remote ACP session id as provider-specific descriptor data and implement `AcpProvider.resume?()` as spawn, initialize, `loadSession`, then prompt. The initial ACP run must verify `initialize.agentCapabilities.loadSession`, and every resumed process must use the same durable backend; replayed history from `loadSession` must not be collected as the new activation's output. Because ACP load support is negotiated per child rather than established solely by the provider method's presence, this follow-up must also define how a start result advertises child-specific continuation before ACP children enter the durable catalog. @@ -103,7 +103,7 @@ Task records and active-run associations are process-local. Persistence makes th **Keep `resume?()` on the disposed run.** Retaining a disposed `SubagentRun` only to call `resume()` makes the old run double as a durable child handle and cannot reconstruct that object after restart. Service dispatch plus provider reconstruction makes the persistence boundary explicit. -**Put control orchestration on `SubagentService`.** This service-placement alternative was later adopted by the [merged-service decision](../simplification/2026-07-26-merge-subagent-control-service.md), which keeps raw start/resume transport reusable while isolating optional Task and persistence work in an injected internal manager. +**Put control orchestration on `SubagentService`.** This service-placement alternative is the [merged-service decision](../simplification/2026-07-26-merge-subagent-control-service.md); the [intent-operation refinement](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md) keeps provider start/resume dispatch reusable only inside the service while isolating optional Task and persistence work in an injected internal manager. **Add explicit activation phases.** Public `starting`/`running`/`settling` states could describe admission and cleanup precisely, but would add a lifecycle protocol the implementation does not otherwise need. The synchronous association install closes duplicate process-local cold resume without exposing those phases. @@ -118,8 +118,8 @@ Task records and active-run associations are process-local. Persistence makes th ## Consequences - Every follow-up after settlement pays persistence load and scoped setup cost; in exchange, live children stay bounded by concurrent work rather than historical session count. Continuable creation fails clearly when persistence is unavailable or the stored composition cannot be reconstructed. -- Two callers may still race a stopped child through paths outside the control service. The Agent registry prevents duplicate same-session publication; a losing Task fails and its message is not delivered. A message may also race cancellation, terminal status publication, or run disposal. Admission is not claimed to be atomic or exactly-once; the synchronous process-local association install closes duplicate cold resume through the control service without a public lifecycle state machine. -- Driving a continuable child through the ordinary Agent API bypasses its Task association. `ctx.subagents` rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through `SubagentService.sendMessage()`. +- Two callers may still race a stopped child through paths outside the continuation manager. The Agent registry prevents duplicate same-session publication; a losing Task fails and its message is not delivered. A message may also race cancellation, terminal status publication, or run disposal. Admission is not claimed to be atomic or exactly-once; the synchronous process-local association install closes duplicate cold resume through `followup` without a public lifecycle state machine. +- Driving a continuable child through the ordinary Agent API bypasses its Task association. `ctx.subagents` rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through `SubagentService.followup()`. - The active-run association coordinates only one runtime. Concurrent resume from multiple processes is not serialized; that deployment requires a persistence-level lease or compare-and-set operation. - Human interaction requires the exact parent Agent instance to remain live because owner disposal cancels and removes its Tasks. It also requires an attached Task control surface. Standalone child interaction requires a future separation between Task access ownership and durable notification targeting. - The background tool returns child and Task ids before child publication and descriptor durability. Startup failure, a failed final durability confirmation, or process exit before the first child flush leaves the Task failed and may leave an unmaterialized or stale child id; by-id control reports missing state as unavailable rather than retroactively changing the tool acknowledgement. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 019b623d44..93f4c5b8ba 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-21-continuable-background-subagents.md) | 中文 -本记录中的服务放置与提供方功能策略已由[将 subagent 控制合并到 subagent 服务](../simplification/2026-07-26-merge-subagent-control-service.md)取代。继续执行、持久化、Task、路由、授权和持久性语义仍然有效;下文所提控制服务,是指现已通过 `ctx.subagents` 公开的内部继续执行管理器。 +本记录中的服务放置与提供方功能策略已由[将 subagent 控制合并到 subagent 服务](../simplification/2026-07-26-merge-subagent-control-service.md)和[以意图命名的 subagent 继续执行操作](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md)取代。继续执行、持久化、Task、路由、授权和持久性语义仍然有效。 ## 问题 @@ -25,39 +25,39 @@ durable child Session activation 3: Task 3 -> SubagentRun -> AgentHandle -> dispose ``` -前台委派保持一次性行为。继续执行覆盖进程内 spawn 和 fork child。提供方支持从持久化存储恢复后,才能将其 child 标记为可继续——`tool-subagent` 会依据所挂载提供方的 `resume` 功能对其后台路由进行分支——在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。 +前台委派保持一次性行为。继续执行覆盖后台的进程内 spawn 和 fork child。每个 `tool-subagent` 实例都会选择 `backgroundMode: 'one-shot' | 'continuable'`;配置为可继续模式时,所挂载提供方必须具备 `resume` 功能,而可恢复的提供方仍可采用一次性后台策略。在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。 -底层 `ctx.subagents` seam 不感知 child 集合、Task 与持久化。它注册提供方,校验并分发 `start` 或 `resume`,观察 run 生命周期,并返回由持有方负责的 run。`SubagentControlService`(`@deepseek-ai/dsh-subagent-control` 中的 `ctx.subagentControl`)负责管理可继续 child 的稳定 id、持久化描述符并按已知 child id 查找、由 Task 支撑的激活,以及消息路由。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器通过该控制服务处理可继续后台工作;前台一次性委派仍直接调用 `ctx.subagents.start()`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。parent 到 child 的枚举与 `list_agents` 属于单独的持久化目录提案。 +`ctx.subagents` 是唯一的公开服务。普通 `start` 不感知 child 集合、Task 与持久化:它校验提供方功能、分发一次激活、观察 run 生命周期,并返回由持有方负责的 run。注入的内部继续执行管理器负责管理稳定的 child id、描述符持久化与查找、由 Task 支撑的激活,以及通过 `startContinuable` 和 `followup` 进行的路由;管理器解析继续执行状态后,提供方的 start 与 resume 分发通过私有闭包进行。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器调用这些意图操作来处理可继续后台工作;前台和一次性后台委派使用普通 `start`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的可选轻量适配器,它是否存在不会决定是否启动可继续工作。parent 到 child 的枚举与 `list_agents` 属于单独的持久化目录提案。 ### Task 与取消的所有权 -初始后台委派请求控制服务启动 child 并注册其 Task。可继续提供方只有在确认本次激活的最终会话状态已持久化后,才会返回成功的 run 结果。Task 结算流程等待该结果,调用 `run.dispose()`(经由控制服务的 `settleRun`),然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。必需的持久性检查点若没有已安装的监听器或任一监听器失败,run 会以稳定错误码 `DURABILITY_FAILED` 拒绝,并将检查点失败保留为失败原因;控制服务会记录失败的 Task,其详情说明最新状态未确认已持久化,因此恢复时可能不可用或已陈旧。 +初始后台委派请求 `ctx.subagents` 启动 child 并注册其 Task。可继续提供方只有在确认本次激活的最终会话状态已持久化后,才会返回成功的 run 结果。Task 结算流程等待该结果,通过继续执行管理器的结算路径调用 `run.dispose()`,然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。必需的持久性检查点若没有已安装的监听器或任一监听器失败,run 会以稳定错误码 `DURABILITY_FAILED` 拒绝,并将检查点失败保留为失败原因;管理器会记录失败的 Task,其详情说明最新状态未确认已持久化,因此恢复时可能不可用或已陈旧。 后续每个轮次都会创建另一个 Task。该轮 producer 持有的执行资源仅服务于这次激活,不属于 child 会话。它只会到达一次终态、只产生一个结果,也不会重新打开。Task 注册表中当前注册的那个存活 parent agent 实例仍是其 owner:dispose 该实例会取消、等待并移除其 Task。Task API 会授权 session id 与该 owner 匹配的调用方,但 id 相同的替代实例不会成为通知或资源清理目标。这一设计保留 `settleRun()` 契约,并使 Task 所拥有的存活 child 数量受并发工作量限制,而不是随历史会话数量增长。 -用户界面适配器打开 child 会话时,只读取持久化 transcript,不会仅为展示而恢复 agent。用户输入通过控制服务,启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。因此,仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。 +用户界面适配器打开 child 会话时,只读取持久化 transcript,不会仅为展示而恢复 agent。用户输入通过继续执行管理器,启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。因此,仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。 -如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 控制插件对的同时,也会挂载 `@deepseek-ai/dsh-tasks` 与 `@deepseek-ai/dsh-tool-tasks`。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 +如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 工具时,也会挂载 `@deepseek-ai/dsh-tasks-local` 与 `@deepseek-ai/dsh-tool-tasks`。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 -取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。`sendMessage()` 要求调用方提供信号;若在线 steering 正在等待请求准入时该信号被中止,激活自有的 controller 会被中止,以便提供方丢弃待处理消息,并且该调用仅在子 agent 完全停稳后结算。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 +取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。`followup()` 要求调用方提供信号;若在线 steering 正在等待请求准入时该信号被中止,激活自有的 controller 会被中止,以便提供方丢弃待处理消息,并且该调用仅在子 agent 完全停稳后结算。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 -从持久化存储恢复的 Task 会在查找描述符或等待任何提供方操作之前,创建由本次激活持有的 `AbortController`;描述符查找、直接 parent 鉴权和描述符归并都在该 Task producer 内部执行,因此同一信号覆盖它们,其失败会将该 Task 结算为 `failed`。对于不接受信号的持久化调用,可以让底层 I/O 执行完毕;但控制服务必须在每次这类 await 返回后重新检查取消状态,如已取消,之后不得开始或发布任何 child 工作。在 Agent 发布前收到中止信号时,提供方必须先回滚其创建事务并达到完全停稳状态,然后才让恢复调用以拒绝结束。Agent 发布后,提供方必须消除创建期间移交取消信号时的竞态,在返回前将同一信号附加到存活 run;之后取消会停止 child 轮次。即使提供方的恢复调用尚未返回 `SubagentRun`,`task_kill` 与对确切 owner 实例的 dispose 仍通过这条路径生效。Task 结算会等待回滚或 run dispose 完成,只有在激活完全停稳后才记录 `killed`。 +从持久化存储恢复的 Task 会在查找描述符或等待任何提供方操作之前,创建由本次激活持有的 `AbortController`;描述符查找、直接 parent 鉴权和描述符归并都在该 Task producer 内部执行,因此同一信号覆盖它们,其失败会将该 Task 结算为 `failed`。对于不接受信号的持久化调用,可以让底层 I/O 执行完毕;但继续执行管理器必须在每次这类 await 返回后重新检查取消状态,如已取消,之后不得开始或发布任何 child 工作。在 Agent 发布前收到中止信号时,提供方必须先回滚其创建事务并达到完全停稳状态,然后才让恢复调用以拒绝结束。Agent 发布后,提供方必须消除创建期间移交取消信号时的竞态,在返回前将同一信号附加到存活 run;之后取消会停止 child 轮次。即使提供方的恢复调用尚未返回 `SubagentRun`,`task_kill` 与对确切 owner 实例的 dispose 仍通过这条路径生效。Task 结算会等待回滚或 run dispose 完成,只有在激活完全停稳后才记录 `killed`。 ### 活跃 run 关联 -控制服务在进程内维护 child session id 到当前 Task 的关联,并在提供方发布后将 run 填入该关联。它会在等待提供方 start 或 resume 之前安装 Task 关联,填入返回的 run,并且只在 run dispose 完成且 Task 终态发布后才移除该关联。该关联只用于让 parent 发送方和用户发送方找到同一次激活;它不是持久化 child 目录、公开的 `ManagedSubagent`、准入预留或 run 状态机。 +继续执行管理器在进程内维护 child session id 到当前 Task 的关联,并在提供方发布后将 run 填入该关联。它会在等待提供方 start 或 resume 之前安装 Task 关联,填入返回的 run,并且只在 run dispose 完成且 Task 终态发布后才移除该关联。该关联只用于让 parent 发送方和用户发送方找到同一次激活;它不是持久化 child 目录、公开的 `ManagedSubagent`、准入预留或 run 状态机。 -对于可继续 child 的初始激活,控制服务会在创建 Task 前分配稳定的 child session id,并通过已完全解析的提供方启动请求(`SubagentStartRequest.continuation`)传递该 id;进程内 spawn 和 fork 会发布这一确切 id,而不是在内部另行分配。后台工具的确认消息会同时公开两种标识,格式为 `started subagent as task `。child id 在多次激活中始终指代同一个持久化对话,Task id 则只指代当前激活。初始 Task 失败,或进程在 child 首次 flush 之前退出,都可能留下一个 **unmaterialized child**:调用方持有 child id,但不存在持久化 header 和描述符。后续按 id 的控制操作会报告该 id 不可用(已启动的 Task 会带着该详情失败),持久化枚举也不会列出它。 +对于可继续 child 的初始激活,继续执行管理器会在创建 Task 前分配稳定的 child session id,并将其作为 `SubagentProviderStartRequest.continuation` 传递;进程内 spawn 和 fork 会发布这一确切 id,而不是在内部另行分配。普通 `SubagentStartRequest` 不含 continuation 字段。后台工具返回规范的 `{ kind: 'background', taskId, subagentId }`,渲染为 `started subagent as task `。child id 在多次激活中始终指代同一个持久化对话,Task id 则只指代当前激活。初始 Task 失败,或进程在 child 首次 flush 之前退出,都可能留下一个 **unmaterialized child**:调用方持有 child id,但不存在持久化 header 和描述符。后续按 id 的操作会报告该 id 不可用(已启动的 Task 会带着该详情失败),持久化枚举也不会列出它。 -每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,控制服务会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:控制服务会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。 +每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,继续执行管理器会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:管理器会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。 -系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且提供确认语义的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 会先同步要求 child 处于 `running` 状态,并拒绝已经提交结构化捕获的 child;随后调用 `Agent.steer()`,等待该消息专属的准入回执。默认循环会为每个 steering 项目提供一份归属于该消息的回执;只有在 `agent/pre-step` 成功后追加该消息、捕获不可变的请求历史并提交 `step/start`,回执才会解析为 `admitted`。终止型轮次策略、取消和 dispose(资源释放)会将待处理回执解析为 `rejected`。非终止型轮次关闭可以把待处理 steering 带入后续排队轮次,但不会确认其准入。提供方必须在调用 `Agent.steer()` 前检查存活状态,避免其 idle 路径在观察到的 run 之外启动轮次。如果查找关联之后、请求获准之前,Task 结算或终止策略率先完成,`steer()` 会拒绝,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且提供确认语义的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 会先同步要求 child 处于 `running` 状态,并拒绝已经提交结构化捕获的 child;随后调用 `Agent.steer()`,等待该消息专属的准入回执。默认循环会为每个 steering 项目提供一份归属于该消息的回执;只有在 `agent/step` 与异步提示词组装成功后,系统追加该消息、捕获不可变的请求历史并提交 `step/start`,回执才会解析为 `admitted`。终止型轮次策略、取消和 dispose(资源释放)会将待处理回执解析为 `rejected`。非终止型轮次关闭可以把待处理 steering 带入后续排队轮次,但不会确认其准入。提供方必须在调用 `Agent.steer()` 前检查存活状态,避免其 idle 路径在观察到的 run 之外启动轮次。如果查找关联之后、请求获准之前,Task 结算或终止策略率先完成,`steer()` 会拒绝,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 -控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 +继续执行管理器不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `followup` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 ### 面向模型的 `send_message` -模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具将后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`,并转发其执行信号;控制服务要求调用方同时提供 `MessageSource` 和取消信号。来源会贯穿在线 steering 和 cold resume 两条路径,而取消只控制尚未完成的在线投递等待,因为 cold resume Task 会立即返回,并自行负责后续取消。child 模型收到的仍是普通的 user role 内容,而持久化的来源信息可防止模型生成的后续消息被归类为直接用户输入。用户适配器则提供 `{ kind: 'user' }` 及其交互信号。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 +模型获得一个由 `SubagentService.followup()` 支撑的 `send_message(subagent_id, message)` 工具,与 `Agent` 上的意图动词一致。该服务操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具将后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`,并转发 `{ source, signal }`;服务要求在一个选项对象中同时提供这两项信息。来源会贯穿在线 steering 和 cold resume 两条路径,而取消只控制尚未完成的在线投递等待,因为 cold resume Task 会立即返回,并自行负责后续取消。child 模型收到的仍是普通的 user role 内容,而持久化的来源信息可防止模型生成的后续消息被归类为直接用户输入。用户适配器则提供 `{ kind: 'user' }` 及其交互信号。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 - 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message, source)` 并返回现有 task id;它不会创建新 Task。 - 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 @@ -67,19 +67,19 @@ durable child Session 发送到现有 run 的消息没有独立结果,其效果体现在当前 Task 的最终结果中。启动的后续轮次具有新 Task 的结果,并使用现有 `task_output` 读取路径。subagent 层不会再注入第二份完成通知。 -用户输入使用同一个控制操作。UI 可以展示 child transcript 和当前 Task 状态,取消操作则以已加载 parent 作为调用方访问 Task 服务。工具 schema 与 UI 适配器消费同一个控制服务契约,不建立彼此独立的执行路径。 +用户输入使用同一个 `followup` 操作。UI 可以展示 child transcript 和当前 Task 状态,取消操作则以已加载 parent 作为调用方访问 Task 服务。工具 schema 与 UI 适配器消费同一个服务契约,不建立彼此独立的执行路径。 ### 持久化 child handle 与从持久化存储恢复 -控制服务在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 +继续执行管理器在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 版本化描述符([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)包含 subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果契约,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`seedLength` 和 `delegationDepth` 的权威信息,持久化 child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。 -从持久化存储恢复不能依赖旧 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。`SubagentRun` 不含 `resume` 操作:run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。原有的 `SubagentRun.sendMessage?()` 功能改名为 `SubagentRun.steer?()`,以免其提供确认语义且仅适用于在线消息的契约与服务编排或面向模型的工具混淆。 +从持久化存储恢复不能依赖 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。`SubagentRun.steer?()` 这一名称明确指代提供确认语义且仅适用于在线消息的功能,以免该功能与服务编排或面向模型的工具混淆。 -`SubagentControlService` 的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。 +内部继续执行管理器的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它通过私有服务闭包传递完全解析的 `SubagentProviderResumeRequest`,其中包含由 Task 持有的取消信号;该闭包只负责在检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentService.followup()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。私有的提供方分发与提供方本身都不会枚举持久化 child 或关联 Task。 -后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终会话 flush;此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,控制服务会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。最终确认期间发生取消时,尚未发布的激活结果由取消操作接管;即使 child 轮次已记录为完成,或之后的检查点失败,也不能取代 Task 的 `killed` 结果。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 +后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终的 `SessionStore.flush()`;返回 `true` 表示至少有一个持久性监听器参与,返回 `false` 表示必需的检查点失败,而拒绝则携带监听器失败。此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,继续执行管理器会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。最终确认期间发生取消时,尚未发布的激活结果由取消操作接管;即使 child 轮次已记录为完成,或之后的检查点失败,也不能取代 Task 的 `killed` 结果。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 TODO(ACP 继续执行):将远端 ACP session id 作为提供方专用描述符数据持久化,并实现 `AcpProvider.resume?()`,依次执行 spawn、initialize、`loadSession` 和 prompt。初始 ACP run 必须检查 `initialize.agentCapabilities.loadSession`,恢复后的每个进程必须使用同一个持久化后端;`loadSession` 回放的历史消息不得计入新激活的输出。由于 ACP 的加载支持是按 child 协商的,不能仅根据提供方是否存在该方法来确定,因此该后续工作还必须定义 start 结果如何声明单个 child 支持继续执行,之后才能将 ACP child 写入持久化目录。 @@ -103,7 +103,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 **在已 dispose 的 run 上保留 `resume?()`。** 如果仅为调用 `resume()` 而保留已 dispose 的 `SubagentRun`,旧 run 会同时充当持久化 child handle,而且进程重启后无法重建该对象。由服务分发、提供方重建,可明确表达持久化边界。 -**将控制编排放在 `SubagentService` 上。** 后来的[服务合并决策](../simplification/2026-07-26-merge-subagent-control-service.md)采用了这一服务放置方案;该方案保持底层 start/resume 传输可复用,同时将可选的 Task 与持久化工作隔离在注入的内部管理器中。 +**将控制编排放在 `SubagentService` 上。** 这一服务放置方案即[服务合并决策](../simplification/2026-07-26-merge-subagent-control-service.md);[意图操作细化](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md)将提供方 start/resume 分发的复用限制在服务内部,同时将可选的 Task 与持久化工作隔离在注入的内部管理器中。 **增加显式激活阶段。** 公开的 `starting`/`running`/`settling` 状态可以准确描述准入和清理,但会引入实现本身并不需要的生命周期协议。同步安装关联无需暴露这些阶段,即可消除进程内重复的 cold resume。 @@ -118,8 +118,8 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 影响 - 每次完成结算后的后续轮次都需要承担持久化加载和作用域 setup 成本;作为交换,存活 child 的数量受并发工作量限制,而不是随历史会话数量增长。持久化不可用或存储的组合配置无法重建时,可继续 child 的创建会明确失败。 -- 两个调用方仍可能通过控制服务外部的路径争抢已停止的 child。Agent 注册表会阻止相同会话的重复发布;失败的 Task 会失败,且其消息不会送达。消息也可能与取消、终态状态发布或 run dispose 发生竞态。准入不承诺原子或恰好执行一次;在进程内同步安装的关联无需公开生命周期状态机,即可通过控制服务消除重复的 cold resume。 -- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。`ctx.subagents` 会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentService.sendMessage()` 提交用户输入。 +- 两个调用方仍可能通过继续执行管理器外部的路径争抢已停止的 child。Agent 注册表会阻止相同会话的重复发布;失败的 Task 会失败,且其消息不会送达。消息也可能与取消、终态状态发布或 run dispose 发生竞态。准入不承诺原子或恰好执行一次;在进程内同步安装的关联无需公开生命周期状态机,即可通过 `followup` 消除重复的 cold resume。 +- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。`ctx.subagents` 会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentService.followup()` 提交用户输入。 - 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。 - 用户交互要求作为 owner 的那个精确 parent Agent 实例保持存活,因为 dispose owner 会取消并移除其 Task。用户交互还要求附加 Task 控制面。若要单独与 child 交互,后续必须将 Task 访问所有权与持久化通知目标分离。 - 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、最终持久性确认失败,或进程在 child 首次 flush 之前退出,都会使 Task 失败,并可能留下 unmaterialized 或陈旧的 child id;按 id 的控制操作会将缺失状态报告为不可用,而不会追溯修改工具确认消息。 diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml index fe5d14796a..f28c1f6a8e 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.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-26-merge-subagent-control-service.md -2026-07-26-merge-subagent-control-service.md: eb8a76dd4dfc5f06deb67608a67c12e061819286 -2026-07-26-merge-subagent-control-service.zh.md: 6599606634a1933790949e8a66df906a0bb9def0 +2026-07-26-merge-subagent-control-service.md: 84995446939d0f47e008bffb38083b1b6e0706de +2026-07-26-merge-subagent-control-service.zh.md: 7f82555159bfea9e00fa4cc2afdcf30382f3f776 diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md index eb8a76dd4d..8499544693 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md @@ -4,13 +4,15 @@ Status: implemented English | [中文](2026-07-26-merge-subagent-control-service.zh.md) +The public operation set is refined by [Intent-named subagent continuation operations](2026-07-27-intent-named-subagent-continuation-operations.md). + ## Problem Continuable-child orchestration originally lived in a separate `ctx.subagentControl` service above the raw `ctx.subagents` provider seam. That split kept provider dispatch independent of Tasks and persistence, and gave model and human adapters one orchestration contract. In practice the two services described one capability family, every continuable caller needed both, and the provider-bound delegation tool had to infer policy from `provider.resume` and inspect whether the control service and `send_message` tool happened to be loaded. This made sibling plugin presence decide execution semantics and coupled starting continuable work to an optional follow-up surface. ## Decision -`SubagentService` is the only public service. It retains raw `start(name, request)` and `resume(name, request)` for callers that own run collection, and exposes `startContinuable(spec)` and `sendMessage(...)` for durable Task-backed activations. The standalone `@deepseek-ai/dsh-subagent-control` package and `ctx.subagentControl` key are deleted; the optional `@deepseek-ai/dsh-tool-subagent-control` package injects `ctx.subagents` directly. +`SubagentService` is the only public service. It exposes ordinary `start(name, request)`, Task-backed `startContinuable(spec)`, and intent-named `followup(...)`; provider resume dispatch remains private to its continuation manager. The standalone `@deepseek-ai/dsh-subagent-control` package and `ctx.subagentControl` key are absent; the optional `@deepseek-ai/dsh-tool-subagent-control` package injects `ctx.subagents` directly. The merged service and its providers expose one `SubagentError` taxonomy. Stable codes distinguish provider lookup and capability failures from continuation routing, authorization, cancellation, persistence, and delivery failures; the removed service does not retain a separate error class. @@ -35,5 +37,5 @@ Each `@deepseek-ai/dsh-tool-subagent` instance selects `backgroundMode: 'one-sho - The service topology has one public key and one package fewer while raw provider dispatch remains usable without Tasks or persistence. - Continuable mode fails at provider mount when the configured provider lacks `resume`; missing Tasks, Agents, or persistence still fail at the earliest operation that requires them. - Follow-up delivery remains optional. Deployments may start and collect continuable work through Task tools without exposing `send_message`. -- The continuation manager is still Task- and persistence-aware inside the `dsh-subagent` package, so the package declares optional peer dependencies on those services even though ordinary `start` and `resume` callers do not need them. +- The continuation manager is still Task- and persistence-aware inside the `dsh-subagent` package, so the package declares optional peer dependencies on those services even though ordinary `start` callers do not need them. - Existing continuation races, authorization, durability, cancellation, and settle-then-dispose semantics are unchanged and remain pinned by the migrated `subagent` tests. diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md index 6599606634..7f82555159 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md @@ -4,13 +4,15 @@ Status: implemented [English](2026-07-26-merge-subagent-control-service.md) | 中文 +公开操作集合由[以意图命名的 subagent 继续执行操作](2026-07-27-intent-named-subagent-continuation-operations.md)进一步细化。 + ## 问题 可继续 child 的编排最初位于原始 `ctx.subagents` 提供方 seam 之上的独立 `ctx.subagentControl` 服务中。该拆分使提供方分发与 Task 和持久化无关,并为模型与人工适配器提供统一的编排契约。实践中,两个服务属于同一组功能,每个可继续调用方都需要二者,而绑定提供方的委派工具必须根据 `provider.resume` 推断策略,并检查控制服务与 `send_message` 工具是否碰巧已加载。如此一来,配套插件是否存在会决定执行语义,并将可继续工作的启动耦合到可选的后续操作接口。 ## 决策 -`SubagentService` 是唯一的公开服务。它为自行收集 run 的调用方保留底层 `start(name, request)` 和 `resume(name, request)`,并公开 `startContinuable(spec)` 与 `sendMessage(...)`,用于具备持久性、由 Task 支撑的激活。系统删除独立的 `@deepseek-ai/dsh-subagent-control` 包(package)和 `ctx.subagentControl` 键;可选的 `@deepseek-ai/dsh-tool-subagent-control` 包则直接注入 `ctx.subagents`。 +`SubagentService` 是唯一的公开服务。它公开普通的 `start(name, request)`、由 Task 支撑的 `startContinuable(spec)`,以及按意图命名的 `followup(...)`;提供方的 resume 分发仍封装在其继续执行管理器内部。独立的 `@deepseek-ai/dsh-subagent-control` 包(package)和 `ctx.subagentControl` 键均不存在;可选的 `@deepseek-ai/dsh-tool-subagent-control` 包则直接注入 `ctx.subagents`。 合并后的服务及其提供方公开一套 `SubagentError` 分类体系。稳定错误码把提供方查找失败和功能检查失败,与继续执行路由、鉴权、取消、持久化和送达失败区分开来;已移除的服务不保留单独的错误类。 @@ -35,5 +37,5 @@ Status: implemented - 服务拓扑少了一个公开键和一个包,同时底层提供方分发仍可在没有 Task 或持久化时使用。 - 配置的提供方缺少 `resume` 时,可继续模式会在提供方挂载阶段失败;缺少 Task、Agent 或持久化时,仍会在需要它们的最早操作处失败。 - 后续消息投递仍为可选功能。部署可以通过 Task 工具启动并收集可继续工作,而不公开 `send_message`。 -- `dsh-subagent` 包内的继续执行管理器仍然感知 Task 和持久化,因此该包会将这些服务声明为可选的对等依赖(peer dependency),即使普通的 `start` 和 `resume` 调用方并不需要它们。 +- `dsh-subagent` 包内的继续执行管理器仍然感知 Task 和持久化,因此该包会将这些服务声明为可选的对等依赖(peer dependency),即使普通的 `start` 调用方并不需要它们。 - 现有的继续执行竞态、授权、持久性、取消及先结算再 dispose 的语义均保持不变,并继续由迁移后的 `subagent` 测试固定。 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml new file mode 100644 index 0000000000..5623e559bc --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.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/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +2026-07-27-intent-named-subagent-continuation-operations.md: 1155e6b2fb89661021ebdbd6310902e74a500078 +2026-07-27-intent-named-subagent-continuation-operations.zh.md: 5f434cd8fbb171ef77a3b1f307029d6ade09f1d6 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md new file mode 100644 index 0000000000..1155e6b2fb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -0,0 +1,36 @@ +# Agent Note: Intent-named subagent continuation operations + +Status: implemented + +English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md) + +## Problem + +Merging continuable-child orchestration into `ctx.subagents` left provider dispatch and caller intent on the same public service. `resume(name, request)` accepted a descriptor, authorized parent, durable child id, and activation signal that only the internal continuation manager could resolve correctly. `sendMessage(...)` exposed transport wording rather than the `followup` intent already used by `Agent`, and its separate source and signal parameters widened an operation every caller had to use atomically. + +The durability boundary also exposed both `SessionStore.flush()` and `flushRequired()`. They performed the same scoped parallel dispatch and differed only in whether an empty listener snapshot was accepted, so the session interface encoded one consumer's policy as a second operation. + +## Decision + +`SubagentService` exposes three execution intents: `start(name, request)` for an ordinary holder-owned run, `startContinuable(spec)` for a durable Task-backed child, and `followup(parent, childId, content, { source, signal })` for later content. The last verb matches `Agent.followup()`, while `SubagentRun.steer()` remains the narrower confirmed live-activation capability. The model-facing tool keeps its stable `send_message` name and delegates routing to `followup()`. + +Caller and provider requests are distinct. `SubagentStartRequest` contains only caller-supplied start data; `SubagentProviderStartRequest` adds service-resolved continuation state. Ordinary `start()` clears that state before provider dispatch. `SubagentProviderResumeRequest` remains part of the provider seam, but `SubagentService.resume()` is absent: the continuation manager loads the descriptor, authorizes the parent, and invokes private provider start/resume closures owned by the service. Provider dispatch still receives the same capability checks and run lifecycle observation without becoming a caller operation. + +`SessionStore.flush(session)` returns `Promise`. It resolves `true` after at least one scoped durability listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Ordinary checkpoints may ignore the boolean. A continuable provider requires `true` at its final result boundary and maps `false` or rejection to `DURABILITY_FAILED`. + +## Alternatives considered + +**Keep public provider resume dispatch.** No production caller outside the continuation manager owns the descriptor lookup, direct-parent authorization, Task cancellation, and activation association needed to call it safely. A public method would expose resolved implementation data without a valid independent intent. + +**Keep `sendMessage` on the service.** The model tool sends a message, but the service operation represents a follow-up that may steer or cold-resume. `followup` aligns with the structural `Agent` interface and does not promise a particular route. + +**Keep `flushRequired()`.** A second method hides only an empty-listener check. Returning participation from the existing barrier keeps dispatch in one implementation and lets each caller state whether absence is acceptable. + +**Fold ordinary and continuable starts together.** A flag would make one method return either an awaited holder-owned run or immediate child/Task identities. Separate intent methods preserve the ownership and timing distinction without a return union. + +## Consequences + +- The Cordis service catalog contains only caller operations; provider reconstruction remains extensible through `SubagentProvider.resume?()` without exposing its resolved request as a service method. +- Follow-up source and cancellation travel in one options object, matching the intent-helper shape on `Agent` while retaining the existing live-delivery and cold-resume semantics. +- Session durability has one barrier operation. Callers that require a backend must inspect its participation result rather than selecting a second dispatch method. +- The `send_message` schema, route results, Task ownership, durable event vocabulary, and model-visible transcript remain unchanged. diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md new file mode 100644 index 0000000000..5f434cd8fb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 按意图命名的 subagent 继续执行操作 + +Status: implemented + +[English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文 + +## 问题 + +将可继续 child 的编排合并到 `ctx.subagents` 后,提供方分发与调用方意图共存于同一个公开服务中。`resume(name, request)` 接受描述符、已鉴权的 parent、持久化 child id 与激活信号,而只有内部继续执行管理器才能正确解析这些数据。`sendMessage(...)` 暴露的是传输层措辞,而不是 `Agent` 已采用的 `followup` 意图;它还将来源与信号拆成独立参数,扩大了操作接口,而每个调用方都必须以原子方式同时使用二者。 + +持久性边界还同时公开了 `SessionStore.flush()` 与 `flushRequired()`。二者执行相同的作用域内并行分发,唯一差别是是否接受空的监听器快照,因此会话接口将一个消费方的策略编码为第二项操作。 + +## 决策 + +`SubagentService` 公开三种执行意图:`start(name, request)` 用于普通的、由持有方负责的 run;`startContinuable(spec)` 用于具备持久性且由 Task 支撑的 child;`followup(parent, childId, content, { source, signal })` 用于投递后续内容。最后一个动词与 `Agent.followup()` 一致,而 `SubagentRun.steer()` 仍是范围更窄的能力,仅向已确认仍在运行的激活提供 steering(中途引导)。面向模型的工具保留稳定的 `send_message` 名称,并将路由委托给 `followup()`。 + +调用方请求与提供方请求相互分离。`SubagentStartRequest` 只包含调用方提供的启动数据;`SubagentProviderStartRequest` 则加入由服务解析的继续执行状态。普通 `start()` 在分发给提供方之前会清除该状态。`SubagentProviderResumeRequest` 仍属于提供方 seam,但 `SubagentService.resume()` 不对外公开:继续执行管理器加载描述符、对 parent 进行鉴权,并调用由服务持有的私有提供方启动与恢复闭包。提供方分发仍会经过相同的功能检查和 run 生命周期观测,而无需将其变成调用方操作。 + +`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。 + +## 已考虑的替代方案 + +**保留公开的提供方恢复分发。** 继续执行管理器之外没有任何生产调用方负责安全调用所需的描述符查找、直接 parent 鉴权、Task 取消与激活关联。公开方法会暴露已解析的实现数据,但并不存在与之对应的合理独立调用意图。 + +**在服务上保留 `sendMessage`。** 面向模型的工具发送消息,但服务操作表达的是后续操作,既可能对运行中的激活执行 steering,也可能从持久化存储恢复。`followup` 与结构化 `Agent` 接口保持一致,也不承诺特定路由。 + +**保留 `flushRequired()`。** 第二个方法只封装了空监听器检查。由现有屏障返回是否有监听器参与,可以让分发只保留一套实现,并让每个调用方自行判定缺少监听器是否可接受。 + +**合并普通启动与可继续启动。** 一个标志会让同一方法要么等待由持有方负责的 run 就绪后返回,要么立即返回 child 和 Task 标识。按意图拆分的方法无需返回值联合类型即可保留所有权与时序差异。 + +## 影响 + +- Cordis 服务目录只包含调用方操作;提供方的重建能力仍可通过 `SubagentProvider.resume?()` 扩展,同时不会将已解析的请求暴露为服务方法。 +- 后续操作的来源与取消信号通过同一个选项对象传递,与 `Agent` 上按意图命名的辅助方法形态一致,同时保留在线投递与从持久化存储恢复的语义。 +- 会话持久性只保留一个屏障操作。需要后端参与的调用方必须检查参与结果,而不是选择第二种分发方法。 +- `send_message` schema、路由结果、Task 所有权、持久化事件词汇与模型可见的 transcript(文本记录)保持不变。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c2158222ee..33bd5777e6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1582,7 +1582,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-fork/src/index.ts:25`](../packages/subagent/subagent-fork/src/index.ts) +Source: [`packages/subagent/subagent-fork/src/index.ts:30`](../packages/subagent/subagent-fork/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` @@ -1596,7 +1596,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-spawn/src/index.ts:20`](../packages/subagent/subagent-spawn/src/index.ts) +Source: [`packages/subagent/subagent-spawn/src/index.ts:25`](../packages/subagent/subagent-spawn/src/index.ts) ## `@deepseek-ai/dsh-system-prompt` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4624b54dec..c7b0734585 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -685,14 +685,12 @@ Source: [`packages/core/session/src/index.ts:93`](../../packages/core/session/sr ### `session/flush` — parallel -Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. An empty listener snapshot is accepted by SessionStore.flush and rejected by SessionStore.flushRequired. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. +Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. ```ts cordis-catalog /** * Awaited parallel durability checkpoint: every listener runs and the - * caller awaits all of them, with no waterfall veto. An empty listener - * snapshot is accepted by {@link SessionStore.flush} and rejected by - * {@link SessionStore.flushRequired}. Scope-filtered dispatch + * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. * @dshScopeScan unsupported @@ -703,7 +701,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:104`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts) ## `settings/*` @@ -796,7 +794,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:166`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -813,7 +811,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -828,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:146`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -850,7 +848,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:156`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:157`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 89a4dd5dfa..ca1d6f75fc 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1635,22 +1635,11 @@ announce(session: Session): void * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the * scoped-dispatch invariant can pin it. * @param session - the session whose buffered events must reach durable storage. - * @returns resolves when every flush listener has settled; after all settle, - * rejects with the first registered listener failure if any listener failed. + * @returns whether at least one durability listener participated, after every + * listener has settled successfully. + * @throws the first registered listener failure after every listener settles. */ -async flush(session: Session): Promise - -/** - * Dispatch the same awaited checkpoint as {@link flush}, but reject when its - * scoped listener snapshot is empty. Callers use this operation when success - * requires an installed durability participant rather than optional - * best-effort persistence. - * @param session - the session whose buffered events must reach durable storage. - * @returns resolves when at least one listener participated and every - * listener settled successfully. - * @throws when no listener is registered or any registered listener fails. - */ -async flushRequired(session: Session): Promise +async flush(session: Session): Promise /** * Look up a live session. @@ -1684,7 +1673,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:766`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:764`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1972,17 +1961,18 @@ Named provider registry with raw and Task-backed continuation operations. startContinuable(spec: ContinuableStartSpec): ContinuableStart /** - * Deliver a message to a continuable child by steering its live activation - * or cold-resuming a fresh Task-backed activation. + * Follow up with a continuable child. A live child is steered and fulfillment + * confirms request admission; an idle child immediately returns a fresh Task + * whose descriptor lookup, authorization, and cold resume may later fail. * @param parent - live direct parent authorizing the operation. * @param childId - durable child session id. - * @param message - user-role content to deliver. - * @param source - durable caller attribution. - * @param signal - caller cancellation; while live delivery awaits admission, - * abort cancels the shared activation so the wait reaches quiescence. + * @param content - user-role content to deliver. + * @param options - durable attribution and caller cancellation; aborting a + * live-delivery wait cancels the shared activation and awaits quiescence. * @returns the existing steered Task or newly started Task. + * @throws when continuation services are unavailable or live delivery is not admitted. */ -sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, signal: AbortSignal, ): Promise +followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise /** * Register a provider under its name. Registration is effect-scoped and HMR @@ -2016,23 +2006,11 @@ list(): string[] * @returns the ready holder-owned run. */ async start(name: string, request: SubagentStartRequest): Promise - -/** - * Resume a persisted continuable child through the named provider's - * `resume` capability, with the same run lifecycle observation as - * {@link start}. The internal continuation manager has already loaded the - * child, folded its descriptor, and authorized the parent; this method owns - * only capability-checked dispatch. - * @param name - the provider recorded in the child's descriptor. - * @param request - the fully resolved resume request. - * @returns the fresh holder-owned run for the resumed activation. - */ -async resume(name: string, request: SubagentResumeRequest): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentResumeRequest](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentFollowupResult](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:198`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:199`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index aaa02e1587..6fd7de2f74 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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/subagent.md -subagent.md: 1321429ac9e6280878016601646dd08981ab2b40 -subagent.zh.md: 072b2d2c1635d7c2c59b5a24d2bafc6ee32f8422 +subagent.md: 2dc25dfb14b1506edf7f53f6ce0d8681fefa98c6 +subagent.zh.md: 00f2748ad92ae37b0a2fe2616d9e052f9c4b916f diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 1321429ac9..2dc25dfb14 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -39,8 +39,8 @@ The tool layer builds this request from the model input and its own config; the /** * What a caller asks for when starting a subagent. The tool layer builds this * from the model's `{ description, prompt }` plus its own config; the service - * validates {@link SubagentCapabilities} against the named provider, then - * passes it to {@link SubagentProvider.start}. + * validates {@link SubagentCapabilities} against the named provider and + * resolves a {@link SubagentProviderStartRequest} for dispatch. */ interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -89,23 +89,36 @@ interface SubagentStartRequest { * persona (strict `{{…}}` interpolation against the registered variables). */ readonly persona?: string - /** - * Continuable-child intent, resolved by `ctx.subagents` before start. - * The provider MUST publish exactly `sessionId` as the child identity - * instead of allocating one internally, and MUST append the snapshotted - * `descriptor` as the child's turn-enclosed `subagent/descriptor` event - * before its first request. Requires {@link SubagentProvider.resume} (the - * continuation capability); the service rejects the request otherwise. - */ - readonly continuation?: SubagentContinuation } ``` `signal` is the single cancellation channel before and after readiness. The [subagent composition-controls Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the persona, live global-tool filter, absolute-depth, and visibility-not-authority rationale. -## Continuable children: `SubagentContinuation` and `SubagentResumeRequest` +Providers receive a separate resolved shape. Raw `SubagentService.start()` clears continuation state, while `startContinuable()` alone supplies the service-allocated identity and descriptor. -A **continuable background subagent** is a durable child session with a series of Task-backed activations. `SubagentService.startContinuable()` allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the resolved start request; the provider publishes exactly that id and appends the descriptor before the initial prompt is admitted. `SubagentService.sendMessage()` loads and authorizes a stopped child before dispatching a fully resolved resume request through the raw `resume()` operation, or steers its live activation. An internal manager owns descriptor lookup and Task association only while `ctx.tasks` and `ctx.agents` exist; persistence is required per continuation operation, not to load the provider registry. `startContinuable()` returns both identities, while `sendMessage()` reports whether the message `steered` the existing Task or `started` a fresh one. Every sender supplies a `MessageSource` and cancellation signal; abort while live delivery awaits admission cancels the shared activation and rejects after quiescence. The optional model-facing tool uses `CoordinatorMessageSource` and its tool-execution signal, while a human adapter uses `{ kind: 'user' }` and its interaction signal. +```ts type-equiv +/** + * Provider-facing start request after the service resolves optional + * continuation state. Ordinary callers use {@link SubagentStartRequest}; only + * the Task-backed continuation path can attach a stable child identity and + * durable descriptor. + */ +interface SubagentProviderStartRequest extends SubagentStartRequest { + /** + * Continuable-child state resolved by `ctx.subagents` before provider dispatch. + * The provider MUST publish exactly `sessionId` as the child identity + * instead of allocating one internally, and MUST append the snapshotted, + * model-hidden `subagent/descriptor` before the initial prompt is admitted. + * Requires {@link SubagentProvider.resume} (the + * continuation capability); the service rejects the request otherwise. + */ + readonly continuation?: SubagentContinuation | undefined +} +``` + +## Continuable children and provider resume + +A **continuable background subagent** is a durable child session with a series of Task-backed activations. `SubagentService.startContinuable()` allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the provider-facing start request; the provider publishes exactly that id and appends the descriptor before the initial prompt is admitted. `SubagentService.followup()` mirrors the intent verb on `Agent`: it steers a live activation or privately dispatches a resolved provider resume after loading and authorizing a stopped child. An internal manager owns descriptor lookup and Task association only while `ctx.tasks` and `ctx.agents` exist; persistence is required per continuation operation, not to load the provider registry. `startContinuable()` returns both identities, while `followup()` reports whether the content `steered` the existing Task or `started` a fresh one. Every sender supplies a `MessageSource` and cancellation signal through one options object; abort while live delivery awaits admission cancels the shared activation and rejects after quiescence. The optional model-facing tool uses `CoordinatorMessageSource` and its tool-execution signal, while a human adapter uses `{ kind: 'user' }` and its interaction signal. ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -118,8 +131,33 @@ interface CoordinatorMessageSource { ```ts type-equiv /** - * The resolved continuable-child identity and durable composition record a - * continuation caller attaches to a start request. + * Options for following up with one continuable child. + */ +interface SubagentFollowupOptions { + /** Durable attribution retained on either live or resumed delivery. */ + readonly source: MessageSource + /** Caller cancellation for a live-delivery admission wait. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** + * How a continuable follow-up was routed: + * `steered` joined the running activation's existing Task without creating a + * Task of its own; `started` created a fresh Task that cold-resumes the + * durable child with the content. Failure is an exception, never a result — + * undelivered content throws. + */ +type SubagentFollowupResult = + | { readonly route: 'steered'; readonly taskId: TaskId } + | { readonly route: 'started'; readonly taskId: TaskId } +``` + +```ts type-equiv +/** + * The resolved continuable-child identity and durable composition record the + * service attaches before provider dispatch. */ interface SubagentContinuation { /** Service-allocated stable child session id, published verbatim. */ @@ -131,14 +169,13 @@ interface SubagentContinuation { ```ts type-equiv /** - * What a caller asks for when resuming a persisted continuable child. The - * continuation manager loads the child log, folds and authorizes its descriptor, - * and passes this fully resolved request to - * {@link SubagentService.resume}, which dispatches to + * Provider-facing request for reconstructing a persisted continuable child. + * The continuation manager loads the child log, folds and authorizes its + * descriptor, then privately dispatches this resolved request to * {@link SubagentProvider.resume}. The provider reconstructs the declared * composition under the live parent's scope and drives one turn with `prompt`. */ -interface SubagentResumeRequest { +interface SubagentProviderResumeRequest { /** The persisted child session id to resume. */ readonly sessionId: SessionId /** The follow-up message that starts the resumed activation's turn. */ @@ -295,22 +332,22 @@ interface SubagentProvider { * fulfillment, the provider owns and cleans all partial resources before this * promise rejects. Ownership transfers to the caller only on fulfillment. */ - start(request: SubagentStartRequest): Promise + start(request: SubagentProviderStartRequest): Promise /** * OPTIONAL (continuation capability): reconstruct a persisted continuable * child from its own transcript and declared descriptor, drive one * follow-up turn, and return a fresh run. Method presence is the capability - * — the service rejects `resume` dispatch and continuable starts on + * — the service rejects continuable starts and cold-resume dispatch on * providers without it. Same publication contract as {@link start}: if * reconstruction fails or `request.signal` aborts before fulfillment, the * provider rolls its creation transaction back to quiescence before * rejecting; after fulfillment the same signal cancels the published run. */ - resume?(request: SubagentResumeRequest): Promise + resume?(request: SubagentProviderResumeRequest): Promise } ``` -`start()` fulfills only with a ready run; `resume()` shares the same publication and lifecycle-observation contract. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. +Provider `start()` fulfills only with a ready run; provider `resume()` shares the same publication and lifecycle-observation contract but is dispatched only by the continuation manager. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. ## In-process backends: depth and seed diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 072b2d2c16..00f2748ad9 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -39,8 +39,8 @@ interface SubagentCapabilities { /** * What a caller asks for when starting a subagent. The tool layer builds this * from the model's `{ description, prompt }` plus its own config; the service - * validates {@link SubagentCapabilities} against the named provider, then - * passes it to {@link SubagentProvider.start}. + * validates {@link SubagentCapabilities} against the named provider and + * resolves a {@link SubagentProviderStartRequest} for dispatch. */ interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -89,23 +89,36 @@ interface SubagentStartRequest { * persona (strict `{{…}}` interpolation against the registered variables). */ readonly persona?: string - /** - * Continuable-child intent, resolved by `ctx.subagents` before start. - * The provider MUST publish exactly `sessionId` as the child identity - * instead of allocating one internally, and MUST append the snapshotted - * `descriptor` as the child's turn-enclosed `subagent/descriptor` event - * before its first request. Requires {@link SubagentProvider.resume} (the - * continuation capability); the service rejects the request otherwise. - */ - readonly continuation?: SubagentContinuation } ``` `signal` 是就绪前后唯一的取消通道。[subagent 组合控制 Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)规定 persona、live 全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 -## 可继续子 agent:`SubagentContinuation` 与 `SubagentResumeRequest` +提供方会接收单独的已解析请求类型。直接调用 `SubagentService.start()` 会清除继续执行状态;只有 `startContinuable()` 才会提供由服务分配的标识和描述符。 -**可继续后台 subagent** 是一份持久化子 agent 会话,由一系列由 Task 支撑的激活组成。`SubagentService.startContinuable()` 会分配稳定的子 agent id、对版本化的 `subagent/descriptor` payload 建立快照,并通过已解析的启动请求传入二者;提供方会准确发布该 id,并在初始 prompt 获准前追加描述符。`SubagentService.sendMessage()` 会先加载并授权已停止的子 agent,再通过底层 `resume()` 操作分发完全解析的恢复请求,或引导其实时激活。只有 `ctx.tasks` 和 `ctx.agents` 存在时,内部管理器才会负责描述符查找与 Task 关联;每项继续执行操作都要求持久化,而加载提供方注册表不要求持久化。`startContinuable()` 返回两个标识,`sendMessage()` 则报告消息是对现有 Task 执行了 `steered`,还是 `started` 一个新 Task。每个发送方都会提供 `MessageSource` 和取消信号;若在在线投递等待准入期间中止该信号,则会取消共享激活,并在其完全停稳后拒绝调用。可选的面向模型工具使用 `CoordinatorMessageSource` 及其工具执行信号,人工适配器则使用 `{ kind: 'user' }` 及其交互信号。 +```ts type-equiv +/** + * Provider-facing start request after the service resolves optional + * continuation state. Ordinary callers use {@link SubagentStartRequest}; only + * the Task-backed continuation path can attach a stable child identity and + * durable descriptor. + */ +interface SubagentProviderStartRequest extends SubagentStartRequest { + /** + * Continuable-child state resolved by `ctx.subagents` before provider dispatch. + * The provider MUST publish exactly `sessionId` as the child identity + * instead of allocating one internally, and MUST append the snapshotted, + * model-hidden `subagent/descriptor` before the initial prompt is admitted. + * Requires {@link SubagentProvider.resume} (the + * continuation capability); the service rejects the request otherwise. + */ + readonly continuation?: SubagentContinuation | undefined +} +``` + +## 可继续子 agent 与提供方恢复 + +**可继续后台 subagent** 是一份持久化子 agent 会话,由一系列由 Task 支撑的激活组成。`SubagentService.startContinuable()` 会分配稳定的子 agent id、对版本化的 `subagent/descriptor` payload 建立快照,并通过面向提供方的启动请求传入二者;提供方会准确发布该 id,并在初始 prompt 获准前追加描述符。`SubagentService.followup()` 沿用 `Agent` 的意图动词:它会引导实时激活,或在加载并授权已停止的子 agent 后,仅在内部向提供方分发已解析的恢复请求。只有 `ctx.tasks` 和 `ctx.agents` 存在时,内部管理器才会负责描述符查找与 Task 关联;每项继续执行操作都要求持久化,而加载提供方注册表不要求持久化。`startContinuable()` 返回两个标识,`followup()` 则报告内容是对现有 Task 执行了 `steered`,还是 `started` 一个新 Task。每个发送方都通过一个选项对象提供 `MessageSource` 和取消信号;若在在线投递等待准入期间中止该信号,则会取消共享激活,并在其完全停稳后拒绝调用。可选的面向模型工具使用 `CoordinatorMessageSource` 及其工具执行信号,人工适配器则使用 `{ kind: 'user' }` 及其交互信号。 ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -118,8 +131,33 @@ interface CoordinatorMessageSource { ```ts type-equiv /** - * The resolved continuable-child identity and durable composition record a - * continuation caller attaches to a start request. + * Options for following up with one continuable child. + */ +interface SubagentFollowupOptions { + /** Durable attribution retained on either live or resumed delivery. */ + readonly source: MessageSource + /** Caller cancellation for a live-delivery admission wait. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** + * How a continuable follow-up was routed: + * `steered` joined the running activation's existing Task without creating a + * Task of its own; `started` created a fresh Task that cold-resumes the + * durable child with the content. Failure is an exception, never a result — + * undelivered content throws. + */ +type SubagentFollowupResult = + | { readonly route: 'steered'; readonly taskId: TaskId } + | { readonly route: 'started'; readonly taskId: TaskId } +``` + +```ts type-equiv +/** + * The resolved continuable-child identity and durable composition record the + * service attaches before provider dispatch. */ interface SubagentContinuation { /** Service-allocated stable child session id, published verbatim. */ @@ -131,14 +169,13 @@ interface SubagentContinuation { ```ts type-equiv /** - * What a caller asks for when resuming a persisted continuable child. The - * continuation manager loads the child log, folds and authorizes its descriptor, - * and passes this fully resolved request to - * {@link SubagentService.resume}, which dispatches to + * Provider-facing request for reconstructing a persisted continuable child. + * The continuation manager loads the child log, folds and authorizes its + * descriptor, then privately dispatches this resolved request to * {@link SubagentProvider.resume}. The provider reconstructs the declared * composition under the live parent's scope and drives one turn with `prompt`. */ -interface SubagentResumeRequest { +interface SubagentProviderResumeRequest { /** The persisted child session id to resume. */ readonly sessionId: SessionId /** The follow-up message that starts the resumed activation's turn. */ @@ -297,22 +334,22 @@ interface SubagentProvider { * fulfillment, the provider owns and cleans all partial resources before this * promise rejects. Ownership transfers to the caller only on fulfillment. */ - start(request: SubagentStartRequest): Promise + start(request: SubagentProviderStartRequest): Promise /** * OPTIONAL (continuation capability): reconstruct a persisted continuable * child from its own transcript and declared descriptor, drive one * follow-up turn, and return a fresh run. Method presence is the capability - * — the service rejects `resume` dispatch and continuable starts on + * — the service rejects continuable starts and cold-resume dispatch on * providers without it. Same publication contract as {@link start}: if * reconstruction fails or `request.signal` aborts before fulfillment, the * provider rolls its creation transaction back to quiescence before * rejecting; after fulfillment the same signal cancels the published run. */ - resume?(request: SubagentResumeRequest): Promise + resume?(request: SubagentProviderResumeRequest): Promise } ``` -`start()` 仅在 run 就绪时 fulfill;`resume()` 采用相同的发布与生命周期观察契约。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 +提供方的 `start()` 仅在 run 就绪时 fulfill;提供方的 `resume()` 采用相同的发布与生命周期观察契约,但只有继续执行管理器会分发它。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 ## 进程内后端:深度与种子 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index fef8b22088..33f004e102 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,14 +37,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:104`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:157`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index f64e05280d..ebef4648df 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -398,7 +398,9 @@ export class BasicCompactService extends CompactService { { owner: null, stability: 'selected-span', - flush: () => this.ctx.sessions.flush(agent.session), + flush: async () => { + await this.ctx.sessions.flush(agent.session) + }, }, signal, ) diff --git a/packages/compact/compact-basic/tests/manual-compact.spec.ts b/packages/compact/compact-basic/tests/manual-compact.spec.ts index aadf50f306..6ce3e18f2a 100644 --- a/packages/compact/compact-basic/tests/manual-compact.spec.ts +++ b/packages/compact/compact-basic/tests/manual-compact.spec.ts @@ -217,7 +217,7 @@ function detachedService(): { ctx: Context; compact: GatedCompactService; flushe let flushes = 0 vi.spyOn(ctx.sessions, 'flush').mockImplementation(() => { flushes += 1 - return Promise.resolve() + return Promise.resolve(false) }) return { ctx, compact: new GatedCompactService(ctx, { auto: false }), flushes: () => flushes } } @@ -730,7 +730,7 @@ describe('compactNow transaction and failure classification', () => { const { ctx, compact } = detachedService() const controller = new AbortController() const reason = new Error('cancelled during flush') - const flushGate = Promise.withResolvers() + const flushGate = Promise.withResolvers() const flush = vi.spyOn(ctx.sessions, 'flush').mockReturnValueOnce(flushGate.promise) const session = closedConversation(2) let released = 0 @@ -750,7 +750,7 @@ describe('compactNow transaction and failure classification', () => { expect(settled).toBe(false) expect(released).toBe(0) - flushGate.resolve(undefined) + flushGate.resolve(false) await expect(running).rejects.toBe(reason) expect(released).toBe(1) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 624e7aab14..224bf25e0b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -743,12 +743,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/** Emit `session/created` exactly once for an {@link enter}ed session (with\n * the carrier {@link enter} captured). Separate from {@link enter} so the\n * caller can yield the detach disposer first (rollback safety — see\n * {@link enter}).\n * @param session - the entered session to announce to listeners.\n * @throws if the session is not live or its announcement already began,\n * including a reentrant call from a creation listener. */', }, { - signature: 'async flush(session: Session): Promise', - jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when every flush listener has settled; after all settle,\n * rejects with the first registered listener failure if any listener failed.\n */', - }, - { - signature: 'async flushRequired(session: Session): Promise', - jsDoc: '/**\n * Dispatch the same awaited checkpoint as {@link flush}, but reject when its\n * scoped listener snapshot is empty. Callers use this operation when success\n * requires an installed durability participant rather than optional\n * best-effort persistence.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when at least one listener participated and every\n * listener settled successfully.\n * @throws when no listener is registered or any registered listener fails.\n */', + signature: 'async flush(session: Session): Promise', + jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns whether at least one durability listener participated, after every\n * listener has settled successfully.\n * @throws the first registered listener failure after every listener settles.\n */', }, { signature: 'get(id: SessionId): Session | undefined', @@ -893,8 +889,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Start one durable continuable child through a Task-backed initial\n * activation.\n * @param spec - provider, Task label, and delegation request.\n * @returns the stable child id and initial activation Task id.\n */', }, { - signature: 'sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, signal: AbortSignal, ): Promise', - jsDoc: '/**\n * Deliver a message to a continuable child by steering its live activation\n * or cold-resuming a fresh Task-backed activation.\n * @param parent - live direct parent authorizing the operation.\n * @param childId - durable child session id.\n * @param message - user-role content to deliver.\n * @param source - durable caller attribution.\n * @param signal - caller cancellation; while live delivery awaits admission,\n * abort cancels the shared activation so the wait reaches quiescence.\n * @returns the existing steered Task or newly started Task.\n */', + signature: 'followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', + jsDoc: '/**\n * Follow up with a continuable child. A live child is steered and fulfillment\n * confirms request admission; an idle child immediately returns a fresh Task\n * whose descriptor lookup, authorization, and cold resume may later fail.\n * @param parent - live direct parent authorizing the operation.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable attribution and caller cancellation; aborting a\n * live-delivery wait cancels the shared activation and awaits quiescence.\n * @returns the existing steered Task or newly started Task.\n * @throws when continuation services are unavailable or live delivery is not admitted.\n */', }, { signature: 'registerProvider(provider: SubagentProvider): () => void', @@ -912,10 +908,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async start(name: string, request: SubagentStartRequest): Promise', jsDoc: '/**\n * Establish a ready child on the named provider. Capability and semantic\n * checks run before delegation. Provider ownership lasts until its promise\n * fulfills; a rejection therefore has no run for the caller to dispose and\n * emits no run lifecycle events.\n * @param name - the provider to use.\n * @param request - child prompt, parent, signal, and optional capabilities.\n * @returns the ready holder-owned run.\n */', }, - { - signature: 'async resume(name: string, request: SubagentResumeRequest): Promise', - jsDoc: '/**\n * Resume a persisted continuable child through the named provider\'s\n * `resume` capability, with the same run lifecycle observation as\n * {@link start}. The internal continuation manager has already loaded the\n * child, folded its descriptor, and authorized the parent; this method owns\n * only capability-checked dispatch.\n * @param name - the provider recorded in the child\'s descriptor.\n * @param request - the fully resolved resume request.\n * @returns the fresh holder-owned run for the resumed activation.\n */', - }, ], }, { @@ -1414,7 +1406,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'session/flush', mode: 'parallel', signature: '\'session/flush\'(this: Scoped, session: Session): Promise | void', - jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. An empty listener\n * snapshot is accepted by {@link SessionStore.flush} and rejected by\n * {@link SessionStore.flushRequired}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', + jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, { @@ -1805,7 +1797,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ContinuableStartSpec', - declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly label: string;\n readonly request: Omit;\n}', + declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly label: string;\n readonly request: Omit;\n}', }, { name: 'CreateAgentOptions', @@ -2355,10 +2347,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SearchResultView', declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;', }, - { - name: 'SendMessageResult', - declaration: 'export type SendMessageResult = {\n readonly route: \'steered\';\n readonly taskId: TaskId;\n} | {\n readonly route: \'started\';\n readonly taskId: TaskId;\n};', - }, { name: 'SendOptions', declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}', @@ -2695,25 +2683,37 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentDescriptorData', declaration: 'export interface SubagentDescriptorData {\n readonly version: number;\n readonly provider: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}', }, + { + name: 'SubagentFollowupOptions', + declaration: 'export interface SubagentFollowupOptions {\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n}', + }, + { + name: 'SubagentFollowupResult', + declaration: 'export type SubagentFollowupResult = {\n readonly route: \'steered\';\n readonly taskId: TaskId;\n} | {\n readonly route: \'started\';\n readonly taskId: TaskId;\n};', + }, { name: 'SubagentProvider', - declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise;\n resume?(request: SubagentResumeRequest): Promise;\n}', + declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentProviderStartRequest): Promise;\n resume?(request: SubagentProviderResumeRequest): Promise;\n}', + }, + { + name: 'SubagentProviderResumeRequest', + declaration: 'export interface SubagentProviderResumeRequest {\n readonly sessionId: SessionId;\n readonly prompt: ContentBlock[];\n readonly source: MessageSource;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly descriptor: SubagentDescriptorData;\n}', + }, + { + name: 'SubagentProviderStartRequest', + declaration: 'export interface SubagentProviderStartRequest extends SubagentStartRequest {\n readonly continuation?: SubagentContinuation | undefined;\n}', }, { name: 'SubagentResult', declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly stopReason: SubagentStopReason;\n}', }, - { - name: 'SubagentResumeRequest', - declaration: 'export interface SubagentResumeRequest {\n readonly sessionId: SessionId;\n readonly prompt: ContentBlock[];\n readonly source: MessageSource;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly descriptor: SubagentDescriptorData;\n}', - }, { name: 'SubagentRun', declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n steer?(content: ContentBlock[], source: MessageSource): Promise;\n}', }, { name: 'SubagentStartRequest', - declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n readonly continuation?: SubagentContinuation;\n}', + declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}', }, { name: 'SubagentStopReason', diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 88ac2bd405..00260f89cb 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/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/core/session/README.md -README.md: 59e8694a957e9742a22662766d671dc2145c44e3 -README.zh.md: 7618bc8f3a9146a4fc5afbfb19317deef7f13068 +README.md: 4730cac913e949d642d049a5c53ab2dd47e10627 +README.zh.md: 12aa1625d7b0568196cd788c2a25cfb869d8780d diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 59e8694a95..4730cac913 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -13,8 +13,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API - `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. -- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. -- `ctx.sessions.flushRequired(session)` uses the same dispatch but also rejects an empty scoped listener snapshot. Callers use it when success requires an installed durability participant rather than optional best-effort persistence. +- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; it returns `true` when at least one listener participated and `false` for an empty snapshot, while unpublished, detached, and stale objects reject. A caller that requires durable storage rejects `false` at its own policy boundary. - `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 7618bc8f3a..12aa1625d7 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -13,8 +13,7 @@ ### 公共 API - `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 -- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 -- `ctx.sessions.flushRequired(session)` 沿用相同的分发逻辑,但也会拒绝空的作用域监听器快照。若成功要求已安装的持久性参与方介入,而不是采用可选的尽力持久化,调用方应使用此方法。 +- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败;至少一个监听器参与时返回 `true`,监听器快照为空时返回 `false`,而未发布、已脱离和陈旧的对象会被拒绝。要求持久化存储的调用方应在自己的策略边界拒绝 `false`。 - `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 - `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 - `ctx.sessions.get(id: SessionId): Session | undefined` diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 55c3a11e83..ad63179156 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -93,9 +93,7 @@ declare module 'cordis' { 'session/event'(this: Scoped, session: Session, event: SessionEvent): void /** * Awaited parallel durability checkpoint: every listener runs and the - * caller awaits all of them, with no waterfall veto. An empty listener - * snapshot is accepted by {@link SessionStore.flush} and rejected by - * {@link SessionStore.flushRequired}. Scope-filtered dispatch + * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. * @dshScopeScan unsupported @@ -970,35 +968,14 @@ export class SessionStore extends Service { * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the * scoped-dispatch invariant can pin it. * @param session - the session whose buffered events must reach durable storage. - * @returns resolves when every flush listener has settled; after all settle, - * rejects with the first registered listener failure if any listener failed. + * @returns whether at least one durability listener participated, after every + * listener has settled successfully. + * @throws the first registered listener failure after every listener settles. */ - async flush(session: Session): Promise { - await this.dispatchFlush(session, false) - } - - /** - * Dispatch the same awaited checkpoint as {@link flush}, but reject when its - * scoped listener snapshot is empty. Callers use this operation when success - * requires an installed durability participant rather than optional - * best-effort persistence. - * @param session - the session whose buffered events must reach durable storage. - * @returns resolves when at least one listener participated and every - * listener settled successfully. - * @throws when no listener is registered or any registered listener fails. - */ - async flushRequired(session: Session): Promise { - await this.dispatchFlush(session, true) - } - - /** Dispatch one optional or required flush listener snapshot. */ - private async dispatchFlush(session: Session, requireListener: boolean): Promise { + async flush(session: Session): Promise { const { carrier } = this.liveEntryFor(session) const callbackArgs: unknown[] = [session] const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session]) - if (requireListener && callbacks.length === 0) { - throw new Error(`session "${session.id}" required durability checkpoint has no registered listener`) - } const results = await Promise.allSettled(callbacks.map((callback) => { try { return callback(...callbackArgs) @@ -1011,6 +988,7 @@ export class SessionStore extends Service { })) const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected') if (failure !== undefined) throw failure.reason + return callbacks.length > 0 } /** Return the exact live entry; detached/prepared objects reject. */ diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index a524d9cb86..ffa436bc8b 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -84,25 +84,16 @@ describe('sessions.flush()', () => { const ctx = await mount() const session = ctx.sessions.create() - await expect(ctx.sessions.flush(session)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(session)).resolves.toBe(false) }) - it('rejects a required flush with no listeners', async () => { - const ctx = await mount() - const session = ctx.sessions.create() - - await expect(ctx.sessions.flushRequired(session)).rejects.toThrow( - `session "${session.id}" required durability checkpoint has no registered listener`, - ) - }) - - it('completes a required flush when a listener succeeds', async () => { + it('reports a participating listener after it succeeds', async () => { const ctx = await mount() const session = ctx.sessions.create() const flushed: Session[] = [] ctx.on('session/flush', current => void flushed.push(current)) - await ctx.sessions.flushRequired(session) + await expect(ctx.sessions.flush(session)).resolves.toBe(true) expect(flushed).toEqual([session]) }) diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts index 9108a29c14..cf5722ff29 100644 --- a/packages/session-persistence/session-checkpoint-policy/src/index.ts +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -76,5 +76,7 @@ export function apply(ctx: Context): void { // Before each request, persist everything committed by the preceding step; // the first step's call is an intentional no-op beyond any prompt intake. - ctx.on('agent/step', (agent): Promise => ctx.sessions.flush(agent.session)) + ctx.on('agent/step', async (agent): Promise => { + await ctx.sessions.flush(agent.session) + }) } diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index d8ee35f4b7..971eed79c6 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -719,7 +719,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(Object.assign((inner: Context) => { reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) - await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(reuse)).resolves.toBe(true) reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(reuse) @@ -796,7 +796,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // A live session with that id arrives and claims it (cursor 0 matches // trivially), persisting its seed. const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) - await expect(ctx.sessions.flush(live)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(live)).resolves.toBe(true) const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim')) // Seeded 0-5 plus the constructor's end-seed event at 6. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6]) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index bca32438f4..eb80683431 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -279,7 +279,7 @@ describe('PersistenceCoordinator eager writes', () => { const barriers = [ctx.sessions.flush(session), ctx.sessions.flush(session)] appendGate.resolve(true) - await expect(Promise.all(barriers)).resolves.toEqual([undefined, undefined]) + await expect(Promise.all(barriers)).resolves.toEqual([true, true]) expect(backend.appendAttempts).toBe(2) expect(backend.store.get(session.id)?.events.map(event => event.seq)).toEqual([0, 1]) } finally { @@ -353,7 +353,7 @@ describe('PersistenceCoordinator stored identity', () => { expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) const resumed = ctx.sessions.create(id, { seed: loaded.events, meta: loaded.meta }) - await expect(ctx.sessions.flush(resumed)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(resumed)).resolves.toBe(true) } finally { loadGate.resolve(true) await fiber.dispose() @@ -592,7 +592,7 @@ describe('PersistenceCoordinator retirement', () => { const reuseFlush = ctx.sessions.flush(reuse) loadGate.resolve(true) - await expect(reuseFlush).resolves.toBeUndefined() + await expect(reuseFlush).resolves.toBe(true) } finally { loadGate.resolve(true) await backendFiber.dispose() diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 37e2556d44..8461795120 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -11,7 +11,12 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SubagentCapabilities, SubagentProvider, SubagentResumeRequest, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import type { + SubagentCapabilities, + SubagentProvider, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, +} from '@deepseek-ai/dsh-subagent' import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-fork' @@ -59,7 +64,7 @@ class ForkProvider implements SubagentProvider { constructor(readonly name: string) {} - start(request: SubagentStartRequest) { + start(request: SubagentProviderStartRequest) { const seed = completedTurnPrefix(request.parent) return startInProcessRun(request, { // Only pass a seed when there's a completed turn to inherit; an empty seed @@ -68,7 +73,7 @@ class ForkProvider implements SubagentProvider { }) } - resume(request: SubagentResumeRequest) { + resume(request: SubagentProviderResumeRequest) { // Cold resume loads the child's OWN persisted transcript, which already // contains the completed-turn prefix captured at initial creation; it // never forks the parent's newer history again. diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 89d9134fcb..25b886b635 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: 525760ccc413bb46ca5ea3a37e610a3ff58b8068 -README.zh.md: 4d02e2bb89f38e449dfd8bf31a39b79891f6a69e +README.md: 8d266e93021285e27e7819386a4de9c33492a796 +README.zh.md: 79450a32a7ecc3cf2a442524a2680614b3f28ed0 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 525760ccc4..8d266e9302 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,7 +14,7 @@ The driver follows this sequence: 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the prepended one-shot `agent/prompt-submit` contribution. It appends the `subagent/descriptor` event before downstream prompt admission can block or throw; allowed admission opens the initial turn afterward, while the final required checkpoint persists the descriptor even when no turn opens. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. For a continuable start or resume, call `child.ctx.sessions.flushRequired(child.session)` again before returning the result. This final confirmation requires an installed durability listener and retries events retained after a failed turn checkpoint; if no listener participates or any listener fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the checkpoint failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior. +5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result and require its participation result to be `true`. This final confirmation retries events retained after a failed turn checkpoint; if no listener participates or any listener fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the checkpoint failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior. 6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records. The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 4d02e2bb89..79450a32a7 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会前置安装一次性的 `agent/prompt-submit` 贡献。它会在下游 prompt admission 能够阻止请求或抛出异常之前追加 `subagent/descriptor` 事件;admission 获准后才会开启初始轮次,即使没有轮次开启,最终的必需检查点仍会持久化该描述符。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flushRequired(child.session)`。这次最终确认要求有已安装的持久性监听器参与,并会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。 +5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`,并要求其参与结果为 `true`。这次最终确认会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。 6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 5e6abdda06..38fd418f39 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -15,10 +15,10 @@ import { createUserMessage, errorChain, type ContentBlock, type MessageSource } import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentDescriptorData, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, SubagentResult, - SubagentResumeRequest, SubagentRun, - SubagentStartRequest, SubagentStopReason, } from '@deepseek-ai/dsh-subagent' // Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve @@ -108,7 +108,7 @@ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescripto * @returns a ready holder-owned run. */ export async function startInProcessRun( - request: SubagentStartRequest, + request: SubagentProviderStartRequest, options: InProcessRunOptions, ): Promise { assertSubagentMaxDepth(request.maxDepth) @@ -197,10 +197,10 @@ export async function startInProcessRun( * (loaded through the parent's persistence-backed registry `resume`), so a * fork child never re-forks current parent history; the persisted header * remains authoritative for lineage and the delegation-depth floor. - * @param request - the fully resolved resume request from the low-level service. + * @param request - the fully resolved resume request from the continuation manager. * @returns a fresh ready holder-owned run for this activation. */ -export async function resumeInProcessRun(request: SubagentResumeRequest): Promise { +export async function resumeInProcessRun(request: SubagentProviderResumeRequest): Promise { if (request.signal.aborted) throw prePublicationAbort() const descriptor = request.descriptor const agentOptions: AgentOptions = { @@ -269,7 +269,10 @@ function driveTurn( await child.whenIdle() if (durability === 'required') { try { - await child.ctx.sessions.flushRequired(child.session) + const participated = await child.ctx.sessions.flush(child.session) + if (!participated) { + throw new Error(`session "${child.id}" required durability checkpoint has no registered listener`) + } } catch (error: unknown) { if (!signal.aborted) { throw new SubagentError( diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index d052a3a1e1..530720596f 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -73,6 +73,21 @@ describe('startInProcessRun', () => { expect(ctx.agents.get(run.id)).toBeUndefined() }) + it('uses explicit child model selectors when the parent has none and preserves its cwd', async () => { + const { ctx } = await setup([textResponse('driver answer')]) + const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}, { cwd: '/workspace' }) + const run = await startInProcessRun({ + ...request(parent), + agentOptions: { provider: 'mock', model: 'mock' }, + }, {}) + + const child = ctx.agents.get(run.id)! + expect(child.options).toMatchObject({ provider: 'mock', model: 'mock' }) + expect(child.session.header.cwd).toBe('/workspace') + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + await run.dispose() + }) + it('rejects a continuable child when no durability listener is registered', async () => { const { parent } = await setup([textResponse('driver answer')]) @@ -346,9 +361,9 @@ describe('startInProcessRun', () => { acceptsNextStep: false, ctx: { sessions: { - flushRequired: () => { + flush: () => { flushes++ - return Promise.resolve() + return Promise.resolve(true) }, }, } as unknown as Context, diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 22594fef2e..0080c31521 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -8,7 +8,12 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type { SubagentCapabilities, SubagentProvider, SubagentResumeRequest, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import type { + SubagentCapabilities, + SubagentProvider, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, +} from '@deepseek-ai/dsh-subagent' import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' @@ -40,14 +45,14 @@ class SpawnProvider implements SubagentProvider { constructor(readonly name: string) {} - start(request: SubagentStartRequest) { + start(request: SubagentProviderStartRequest) { // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ // depth, drives the one-shot (including the structured capture when the // request carries an outputSchema), and maps the result. return startInProcessRun(request, {}) } - resume(request: SubagentResumeRequest) { + resume(request: SubagentProviderResumeRequest) { // Cold resume reconstructs the persisted child from its own transcript // under the live parent scope; the shared driver drives the follow-up turn. return resumeInProcessRun(request) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 7ae82932d2..0d8b499482 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: c0811eb3bd76543b4a07e7242772e38bd51db67e -README.zh.md: 6d346ee423af8e242c58486164cdef85d241b53d +README.md: a484352c486c067058bef806bad3bcd7623cf6cc +README.zh.md: 9a750d5dfa22c5df199cdb22e7de6207841d2803 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index c0811eb3bd..a484352c48 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -21,19 +21,18 @@ Multiple providers may coexist under different names. This lets a deployment exp ## Service API -`SubagentService` has seven main operations: +`SubagentService` has six main operations: | Member | Meaning | |---|---| | `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. | | `getProvider(name)` | Return the provider, or `undefined` when absent. | | `list()` | Return provider names in insertion order. | -| `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. | -| `resume(name, request)` | Capability-checked raw dispatch to `provider.resume?()` with the same run lifecycle observation as `start`; the caller owns descriptor lookup, authorization, and collection. | +| `start(name, request)` | Validate an ordinary caller request, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuation state cannot enter through this operation. | | `startContinuable(spec)` | Allocate a durable child id and register its initial Task-backed activation. Requires `ctx.tasks`, `ctx.agents`, session persistence, and a resumable provider. | -| `sendMessage(parent, childId, message, source, signal)` | Steer the current activation or start a new Task that cold-resumes the durable child. Aborting `signal` while live delivery awaits admission cancels the shared activation and rejects after it reaches quiescence. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. | +| `followup(parent, childId, content, { source, signal })` | Follow up with a durable child, matching `Agent.followup()` terminology. It steers the current activation or starts a new Task that cold-resumes the child. Aborting `signal` while live delivery awaits admission cancels the shared activation and rejects after quiescence. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. | -`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, set a child persona, or carry a resolved `continuation` (the control-allocated stable child id plus its durable descriptor), which requires the provider's `resume` capability. +`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. Only the internal continuation manager can add a stable child id and durable descriptor to the provider-facing `SubagentProviderStartRequest`; cold provider resume is likewise private dispatch after descriptor lookup and parent authorization. Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries. @@ -60,13 +59,13 @@ The seam owns the depth vocabulary shared by implementations and consumers: the ## Ownership and lifecycle -`provider.start(request): Promise` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. `provider.resume?(request)` shares the same contract for a resumed activation. +`provider.start(request): Promise` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. `provider.resume?(request)` shares the same contract for a resumed activation; only the continuation manager dispatches it. `SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. For a continuable activation, a completed result also confirms that the provider made its final state durable; a failed required checkpoint rejects as infrastructure rather than publishing unconfirmed output. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the service-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. -The service emits `subagent/start` only after `start()` or `resume()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names. +The service emits `subagent/start` only after an ordinary start or privately dispatched provider resume has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names. Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 6d346ee423..9a750d5dfa 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -21,19 +21,18 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 服务 API -`SubagentService` 有七个主要操作: +`SubagentService` 有六个主要操作: | 成员 | 含义 | |---|---| | `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 | | `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 | | `list()` | 按插入顺序返回提供方名称。 | -| `start(name, request)` | 校验请求的能力和语义值,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。 | -| `resume(name, request)` | 在功能检查后将底层请求分发给 `provider.resume?()`,并沿用与 `start` 相同的运行生命周期观察;描述符查找、授权与收集由调用方负责。 | +| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。此操作不允许传入继续执行状态。 | | `startContinuable(spec)` | 分配持久化子 agent id,并注册其初始的由 Task 支撑的激活。要求 `ctx.tasks`、`ctx.agents`、会话持久化及可恢复的提供方。 | -| `sendMessage(parent, childId, message, source, signal)` | 引导当前激活,或启动新 Task 从持久化存储恢复子 agent。若在在线投递等待准入期间中止 `signal`,则会取消共享激活,并在其完全停稳后拒绝该调用。要求 `ctx.tasks` 和 `ctx.agents`;从持久化存储恢复还要求会话持久化。 | +| `followup(parent, childId, content, { source, signal })` | 对持久化子 agent 执行后续操作,术语与 `Agent.followup()` 一致。它会引导当前激活,或启动新 Task 从持久化存储恢复该子 agent。若在在线投递等待准入期间中止 `signal`,则会取消共享激活,并在其完全停稳后拒绝该调用。要求 `ctx.tasks` 和 `ctx.agents`;从持久化存储恢复还要求会话持久化。 | -`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具、设置子 agent persona,或携带已解析的 `continuation`(由控制层分配的稳定子 agent id 及其持久化描述符);后者要求提供方具备 `resume` 功能。 +`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。只有内部继续执行管理器才能把稳定子 agent id 和持久化描述符添加到面向提供方的 `SubagentProviderStartRequest`;从持久化存储恢复时,向提供方的请求同样只会在查找描述符并授权父级后由内部管理器分发。 同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 @@ -60,13 +59,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 所有权与生命周期 -`provider.start(request): Promise` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。`provider.resume?(request)` 对恢复后的激活采用相同契约。 +`provider.start(request): Promise` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。`provider.resume?(request)` 对恢复后的激活采用相同契约;只有继续执行管理器会分发该请求。 `SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。对于可继续激活,完成的结果还会确认提供方已使其最终状态具备持久性;必需检查点失败会作为基础设施故障拒绝,而不会发布未经确认的输出。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。 本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。可继续启动会准确发布由服务分配的 `continuation.sessionId`。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。 -服务只会在 `start()` 或 `resume()` 兑现后发出 `subagent/start`。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 +服务只会在普通启动或内部向提供方分发的恢复操作兑现后发出 `subagent/start`。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index cd3f7f1f76..1f4aa54a0c 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -21,8 +21,13 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' -import type { SubagentResult, SubagentRun, SubagentStartRequest } from './types.ts' -import type { SubagentService } from './index.ts' +import type { + SubagentProviderResumeRequest, + SubagentProviderStartRequest, + SubagentResult, + SubagentRun, + SubagentStartRequest, +} from './types.ts' import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks' import { SubagentError } from './error.ts' @@ -50,10 +55,10 @@ export interface ContinuableStartSpec { * durable descriptor, then supplies the Task-owned cancellation signal and * `continuation` itself. */ - readonly request: Omit + readonly request: Omit } -/** Identities returned by {@link SubagentContinuationManager.startContinuable}. */ +/** Identities returned by a continuable start. */ export interface ContinuableStart { /** The durable child session id, stable across activations. */ readonly childId: SessionId @@ -62,16 +67,29 @@ export interface ContinuableStart { } /** - * How {@link SubagentContinuationManager.sendMessage} delivered a message: + * Options for following up with one continuable child. + */ +export interface SubagentFollowupOptions { + /** Durable attribution retained on either live or resumed delivery. */ + readonly source: MessageSource + /** Caller cancellation for a live-delivery admission wait. */ + readonly signal: AbortSignal +} + +/** + * How a continuable follow-up was routed: * `steered` joined the running activation's existing Task without creating a * Task of its own; `started` created a fresh Task that cold-resumes the - * durable child with the message. Failure is an exception, never a result — - * an undelivered message throws. + * durable child with the content. Failure is an exception, never a result — + * undelivered content throws. */ -export type SendMessageResult = +export type SubagentFollowupResult = | { readonly route: 'steered'; readonly taskId: TaskId } | { readonly route: 'started'; readonly taskId: TaskId } +type StartProvider = (name: string, request: SubagentProviderStartRequest) => Promise +type ResumeProvider = (request: SubagentProviderResumeRequest) => Promise + /** * One child's current process-local activation: its Task and, after provider * publication, its run. Installed before any provider or persistence await @@ -98,7 +116,7 @@ interface ActiveActivation { * @param result - child terminal result. * @returns outcome for the `ctx.tasks` registration. */ -export function runOutcome(result: SubagentResult): TaskOutcome { +function runOutcome(result: SubagentResult): TaskOutcome { switch (result.stopReason) { case 'completed': return { status: 'completed', output: finalText(result.output) } @@ -154,7 +172,7 @@ function finalText(blocks: ContentBlock[]): string { /** * The continuable-subagent orchestration service. Tool schema and UI adapters * are consumers of this one contract: parent and human messages route through - * {@link sendMessage} and share one activation result and cancellation + * {@link followup} and share one activation result and cancellation * boundary, while foreground one-shot delegation keeps calling * `ctx.subagents.start()` directly. */ @@ -164,7 +182,8 @@ export class SubagentContinuationManager { constructor( private readonly ctx: Context, - private readonly subagents: SubagentService, + private readonly startProvider: StartProvider, + private readonly resumeProvider: ResumeProvider, ) { // Terminal publication is one of the two removal conditions. The exact // Task id pins the resolution to this activation, never a later same-child one. @@ -224,7 +243,7 @@ export class SubagentContinuationManager { ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, }) const taskId = this.startActivation(childId, spec.label, request.parent, signal => - this.subagents.start(spec.provider, { + this.startProvider(spec.provider, { ...request, signal, continuation: { sessionId: childId, descriptor }, @@ -233,7 +252,7 @@ export class SubagentContinuationManager { } /** - * Deliver one message to a known continuable child: steer its running + * Follow up with a known continuable child: steer its running * activation, or cold-resume the durable session into a fresh Task-backed * activation. The two routes are reported distinctly so timing-dependent * routing is observable. Rejection means the message was NOT delivered — in @@ -246,28 +265,36 @@ export class SubagentContinuationManager { * @param parent - the live parent agent sending the message (model tool or * human adapter); Task access is authorized by its session id. * @param childId - the stable child session id. - * @param message - the user-role content to deliver. - * @param source - caller-supplied attribution retained across either route. - * @param signal - caller cancellation. During live delivery, abort cancels - * the shared activation and rejects only after it reaches quiescence. - * @returns whether the message `steered` the existing Task or `started` a new one. + * @param content - the user-role content to deliver. + * @param options - caller attribution and cancellation. During live delivery, + * abort cancels the shared activation and rejects only after quiescence. + * @returns whether the content `steered` the existing Task or `started` a new one. */ - async sendMessage( + async followup( parent: Agent, childId: SessionId, - message: ContentBlock[], - source: MessageSource, - signal: AbortSignal, - ): Promise { + content: ContentBlock[], + options: SubagentFollowupOptions, + ): Promise { this.assertOwnership(childId) const activation = this.activations.get(childId) if (activation !== undefined) { return { route: 'steered', - taskId: await this.steerActivation(activation, parent, childId, message, source, signal), + taskId: await this.steerActivation( + activation, + parent, + childId, + content, + options.source, + options.signal, + ), } } - return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) } + return { + route: 'started', + taskId: this.resumeActivation(parent, childId, content, options.source), + } } /** @@ -422,7 +449,7 @@ export class SubagentContinuationManager { 'NOT_RESUMABLE', ) } - return this.subagents.resume(descriptor.provider, { + return this.resumeProvider({ sessionId: childId, prompt: message, source, diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 902f9bcaea..508388e4f5 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -13,11 +13,11 @@ * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. * - * Raw `start` and `resume` remain collection-agnostic provider dispatch. - * When `ctx.tasks` and `ctx.agents` are available, the same service also binds - * an internal continuation manager for durable child ids, descriptor lookup, - * Task-backed activations, and steer-or-resume delivery. Persistence remains - * optional and is required only when a continuation operation is called. + * Public operations express caller intent: `start` returns one ready owned run, + * `startContinuable` starts a Task-backed durable child, and `followup` routes + * later content without exposing whether the child is live. Provider resume + * dispatch stays private because only the continuation manager holds the + * resolved descriptor and authorization facts. * * Same-process providers are trusted typed collaborators. Requests, provider * descriptors, results, and lifecycle payloads are borrowed immutable values; @@ -32,14 +32,15 @@ import { Context, Service } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentCapabilities, SubagentProvider, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, SubagentResult, - SubagentResumeRequest, SubagentRun, SubagentStartRequest, } from './types.ts' @@ -49,7 +50,8 @@ import SubagentContinuationManager from './continuation.ts' import type { ContinuableStart, ContinuableStartSpec, - SendMessageResult, + SubagentFollowupOptions, + SubagentFollowupResult, } from './continuation.ts' export * from './out-of-process.ts' @@ -58,8 +60,9 @@ export type { SubagentCapabilities, SubagentContinuation, SubagentProvider, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, SubagentResult, - SubagentResumeRequest, SubagentRun, SubagentStartRequest, SubagentStopReason, @@ -72,15 +75,13 @@ export { } from './descriptor.ts' export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts' export { SubagentError } from './error.ts' -export { - runOutcome, - settleRun, -} from './continuation.ts' +export { settleRun } from './continuation.ts' export type { ContinuableStart, ContinuableStartSpec, CoordinatorMessageSource, - SendMessageResult, + SubagentFollowupOptions, + SubagentFollowupResult, } from './continuation.ts' declare module '@deepseek-ai/dsh-agent' { @@ -202,7 +203,11 @@ export class SubagentService extends Service { constructor(ctx: Context) { super(ctx, 'subagents') ctx.inject(['tasks', 'agents'], (childCtx: Context) => { - const manager = new SubagentContinuationManager(childCtx, this) + const manager = new SubagentContinuationManager( + childCtx, + (name, request) => this.startProvider(name, request), + request => this.resumeProvider(request), + ) this.continuations = manager childCtx.effect(() => () => { /* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */ @@ -222,24 +227,24 @@ export class SubagentService extends Service { } /** - * Deliver a message to a continuable child by steering its live activation - * or cold-resuming a fresh Task-backed activation. + * Follow up with a continuable child. A live child is steered and fulfillment + * confirms request admission; an idle child immediately returns a fresh Task + * whose descriptor lookup, authorization, and cold resume may later fail. * @param parent - live direct parent authorizing the operation. * @param childId - durable child session id. - * @param message - user-role content to deliver. - * @param source - durable caller attribution. - * @param signal - caller cancellation; while live delivery awaits admission, - * abort cancels the shared activation so the wait reaches quiescence. + * @param content - user-role content to deliver. + * @param options - durable attribution and caller cancellation; aborting a + * live-delivery wait cancels the shared activation and awaits quiescence. * @returns the existing steered Task or newly started Task. + * @throws when continuation services are unavailable or live delivery is not admitted. */ - sendMessage( + followup( parent: Agent, childId: SessionId, - message: ContentBlock[], - source: MessageSource, - signal: AbortSignal, - ): Promise { - return this.requireContinuations().sendMessage(parent, childId, message, source, signal) + content: ContentBlock[], + options: SubagentFollowupOptions, + ): Promise { + return this.requireContinuations().followup(parent, childId, content, options) } /** @@ -294,6 +299,16 @@ export class SubagentService extends Service { * @returns the ready holder-owned run. */ async start(name: string, request: SubagentStartRequest): Promise { + // A provider request is structurally assignable to the caller shape. Clear + // its wider field so only startContinuable can supply service-owned state. + return this.startProvider(name, { ...request, continuation: undefined }) + } + + /** Validate and dispatch one ordinary or service-resolved provider start. */ + private async startProvider( + name: string, + request: SubagentProviderStartRequest, + ): Promise { const provider = this.expectProvider(name) this.assertCapabilities(provider, request) assertSubagentMaxDepth(request.maxDepth) @@ -308,17 +323,9 @@ export class SubagentService extends Service { return this.observeRun(name, request.parent, await provider.start(request)) } - /** - * Resume a persisted continuable child through the named provider's - * `resume` capability, with the same run lifecycle observation as - * {@link start}. The internal continuation manager has already loaded the - * child, folded its descriptor, and authorized the parent; this method owns - * only capability-checked dispatch. - * @param name - the provider recorded in the child's descriptor. - * @param request - the fully resolved resume request. - * @returns the fresh holder-owned run for the resumed activation. - */ - async resume(name: string, request: SubagentResumeRequest): Promise { + /** Dispatch one authorized provider resume and observe its run lifecycle. */ + private async resumeProvider(request: SubagentProviderResumeRequest): Promise { + const name = request.descriptor.provider const provider = this.expectProvider(name) if (provider.resume === undefined) { throw new SubagentError( diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 50922b183e..da75ae67cc 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -43,8 +43,8 @@ export interface SubagentCapabilities { /** * What a caller asks for when starting a subagent. The tool layer builds this * from the model's `{ description, prompt }` plus its own config; the service - * validates {@link SubagentCapabilities} against the named provider, then - * passes it to {@link SubagentProvider.start}. + * validates {@link SubagentCapabilities} against the named provider and + * resolves a {@link SubagentProviderStartRequest} for dispatch. */ export interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -93,20 +93,29 @@ export interface SubagentStartRequest { * persona (strict `{{…}}` interpolation against the registered variables). */ readonly persona?: string - /** - * Continuable-child intent, resolved by `ctx.subagents` before start. - * The provider MUST publish exactly `sessionId` as the child identity - * instead of allocating one internally, and MUST append the snapshotted - * `descriptor` as the child's turn-enclosed `subagent/descriptor` event - * before its first request. Requires {@link SubagentProvider.resume} (the - * continuation capability); the service rejects the request otherwise. - */ - readonly continuation?: SubagentContinuation } /** - * The resolved continuable-child identity and durable composition record a - * continuation caller attaches to a start request. + * Provider-facing start request after the service resolves optional + * continuation state. Ordinary callers use {@link SubagentStartRequest}; only + * the Task-backed continuation path can attach a stable child identity and + * durable descriptor. + */ +export interface SubagentProviderStartRequest extends SubagentStartRequest { + /** + * Continuable-child state resolved by `ctx.subagents` before provider dispatch. + * The provider MUST publish exactly `sessionId` as the child identity + * instead of allocating one internally, and MUST append the snapshotted, + * model-hidden `subagent/descriptor` before the initial prompt is admitted. + * Requires {@link SubagentProvider.resume} (the + * continuation capability); the service rejects the request otherwise. + */ + readonly continuation?: SubagentContinuation | undefined +} + +/** + * The resolved continuable-child identity and durable composition record the + * service attaches before provider dispatch. */ export interface SubagentContinuation { /** Service-allocated stable child session id, published verbatim. */ @@ -116,14 +125,13 @@ export interface SubagentContinuation { } /** - * What a caller asks for when resuming a persisted continuable child. The - * continuation manager loads the child log, folds and authorizes its descriptor, - * and passes this fully resolved request to - * {@link SubagentService.resume}, which dispatches to + * Provider-facing request for reconstructing a persisted continuable child. + * The continuation manager loads the child log, folds and authorizes its + * descriptor, then privately dispatches this resolved request to * {@link SubagentProvider.resume}. The provider reconstructs the declared * composition under the live parent's scope and drives one turn with `prompt`. */ -export interface SubagentResumeRequest { +export interface SubagentProviderResumeRequest { /** The persisted child session id to resume. */ readonly sessionId: SessionId /** The follow-up message that starts the resumed activation's turn. */ @@ -257,16 +265,16 @@ export interface SubagentProvider { * fulfillment, the provider owns and cleans all partial resources before this * promise rejects. Ownership transfers to the caller only on fulfillment. */ - start(request: SubagentStartRequest): Promise + start(request: SubagentProviderStartRequest): Promise /** * OPTIONAL (continuation capability): reconstruct a persisted continuable * child from its own transcript and declared descriptor, drive one * follow-up turn, and return a fresh run. Method presence is the capability - * — the service rejects `resume` dispatch and continuable starts on + * — the service rejects continuable starts and cold-resume dispatch on * providers without it. Same publication contract as {@link start}: if * reconstruction fails or `request.signal` aborts before fulfillment, the * provider rolls its creation transaction back to quiescence before * rejecting; after fulfillment the same signal cancels the published run. */ - resume?(request: SubagentResumeRequest): Promise + resume?(request: SubagentProviderResumeRequest): Promise } diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 7cf2ca91b8..24392d847e 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -18,7 +18,6 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService, { - runOutcome, settleRun, SubagentError, SUBAGENT_DESCRIPTOR_VERSION, @@ -121,14 +120,17 @@ const coordinatorSource = { } as const const testSendSignal = new AbortController().signal -function sendMessage( +function followup( ctx: Context, parent: Agent, childId: SessionId, content: ReturnType, signal: AbortSignal = testSendSignal, ) { - return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' }, signal) + return ctx.subagents.followup(parent, childId, content, { + source: { kind: 'user' }, + signal, + }) } describe('SubagentService.startContinuable', () => { @@ -145,6 +147,24 @@ describe('SubagentService.startContinuable', () => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) + it('fails a continuable Task before dispatch when its provider has no resume capability', async () => { + const { ctx, parent } = await setup([]) + const start = vi.fn(async () => { throw new Error('must not dispatch') }) + ctx.subagents.registerProvider({ + name: 'one-shot', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start, + }) + + const started = ctx.subagents.startContinuable(startSpec(parent, 'one-shot')) + const snapshot = await waitTerminal(ctx, started.taskId, parent) + + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('does not support continuable children') + expect(start).not.toHaveBeenCalled() + }) + it('fails the Task when persistence detaches before the activation completes', async () => { const releaseResponse = Promise.withResolvers() const adapter = new GatedAdapter([ @@ -270,7 +290,7 @@ describe('SubagentService.startContinuable', () => { expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain('maxDepth') // The unmaterialized child id is reported unavailable on later use. - const followUp = await sendMessage(ctx, parent, started.childId, message('hello?')) + const followUp = await followup(ctx, parent, started.childId, message('hello?')) expect(followUp.route).toBe('started') const failed = await waitTerminal(ctx, followUp.taskId, parent) expect(failed.status).toBe('failed') @@ -313,7 +333,22 @@ describe('SubagentService.startContinuable', () => { }) }) -describe('SubagentService.sendMessage', () => { +describe('SubagentService.followup', () => { + it('fails a cold-resume Task when the provider loses its resume capability', async () => { + const { ctx, parent } = await setup([textResponse('first answer')]) + const started = ctx.subagents.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + + const provider = ctx.subagents.getProvider('spawn')! + Object.defineProperty(provider, 'resume', { value: undefined, configurable: true }) + + const next = await followup(ctx, parent, started.childId, message('continue')) + const snapshot = await waitTerminal(ctx, next.taskId, parent) + + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('does not support resuming persisted children') + }) + it('omits undeclared model selectors and rejects a provider without live delivery', async () => { const { ctx } = await setup([]) const result = Promise.withResolvers<{ @@ -341,14 +376,14 @@ describe('SubagentService.sendMessage', () => { await waitPublishedRun(ctx, started.childId) expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) - await expect(sendMessage(ctx, parent, started.childId, message('join'))) + await expect(followup(ctx, parent, started.childId, message('join'))) .rejects.toThrow(/provider does not accept live delivery/) let terminalDeliveryError: unknown let terminalDelivery: Promise | undefined ctx.tasks.onTaskDone((snapshot) => { if (snapshot.id !== started.taskId) return - terminalDelivery = sendMessage(ctx, parent, started.childId, message('after terminal')).then( + terminalDelivery = followup(ctx, parent, started.childId, message('after terminal')).then( () => undefined, (error: unknown) => { terminalDeliveryError = error @@ -390,7 +425,7 @@ describe('SubagentService.sendMessage', () => { const started = ctx.subagents.startContinuable(startSpec(parent, 'mismatched-local')) await waitPublishedRun(ctx, started.childId) - await expect(sendMessage(ctx, parent, started.childId, message('join'))) + await expect(followup(ctx, parent, started.childId, message('join'))) .rejects.toThrow(/registry agent is not the associated activation's agent/) result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) await waitTerminal(ctx, started.taskId, parent) @@ -419,12 +454,11 @@ describe('SubagentService.sendMessage', () => { }, 5) }) - const delivery = ctx.subagents.sendMessage( + const delivery = ctx.subagents.followup( parent, started.childId, message('also consider Y'), - coordinatorSource, - testSendSignal, + { source: coordinatorSource, signal: testSendSignal }, ) releaseFirst() const delivered = await delivery @@ -450,7 +484,7 @@ describe('SubagentService.sendMessage', () => { const controller = new AbortController() controller.abort('caller already cancelled') - await expect(sendMessage( + await expect(followup( ctx, parent, started.childId, @@ -492,12 +526,11 @@ describe('SubagentService.sendMessage', () => { }) await startedTool.promise - const delivery = ctx.subagents.sendMessage( + const delivery = ctx.subagents.followup( parent, started.childId, message('follow-up that terminal policy rejects'), - coordinatorSource, - testSendSignal, + { source: coordinatorSource, signal: testSendSignal }, ) releaseTool.resolve(undefined) await expect(delivery).rejects.toThrow(/message was not delivered/) @@ -515,12 +548,11 @@ describe('SubagentService.sendMessage', () => { await waitTerminal(ctx, started.taskId, parent) expect(ctx.agents.get(started.childId)).toBeUndefined() - const followUp = await ctx.subagents.sendMessage( + const followUp = await ctx.subagents.followup( parent, started.childId, message('and then?'), - coordinatorSource, - testSendSignal, + { source: coordinatorSource, signal: testSendSignal }, ) expect(followUp.route).toBe('started') expect(followUp.taskId).not.toBe(started.taskId) @@ -559,7 +591,7 @@ describe('SubagentService.sendMessage', () => { expect(descriptor?.data.persona).toBe('You are the resumable child.') expect(descriptor?.data.toolFilter).toEqual({ deny: [] }) - const followUp = await sendMessage(ctx, parent, started.childId, message('continue')) + const followUp = await followup(ctx, parent, started.childId, message('continue')) const snapshot = await waitTerminal(ctx, followUp.taskId, parent) expect(snapshot.status).toBe('completed') // The resumed child's system prompt carried the persona back. @@ -588,7 +620,7 @@ describe('SubagentService.sendMessage', () => { parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } })) await parent.whenIdle() - const followUp = await sendMessage(ctx, parent, started.childId, message('follow up')) + const followUp = await followup(ctx, parent, started.childId, message('follow up')) await waitTerminal(ctx, followUp.taskId, parent) const resumed = await ctx.sessionPersistence.load(started.childId) // The persisted seed boundary is unchanged and parent turn two is absent. @@ -604,7 +636,7 @@ describe('SubagentService.sendMessage', () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) - const followUp = await sendMessage(ctx, parent, started.childId, message('go on')) + const followUp = await followup(ctx, parent, started.childId, message('go on')) const childAgents: Agent[] = [] const stop = ctx.on('agent/created', (agent: Agent) => { @@ -624,7 +656,7 @@ describe('SubagentService.sendMessage', () => { const started = ctx.subagents.startContinuable(startSpec(otherParent)) await waitTerminal(ctx, started.taskId, otherParent) - const attempt = await sendMessage(ctx, parent, started.childId, message('mine now')) + const attempt = await followup(ctx, parent, started.childId, message('mine now')) expect(attempt.route).toBe('started') const snapshot = await waitTerminal(ctx, attempt.taskId, parent) expect(snapshot.status).toBe('failed') @@ -643,7 +675,7 @@ describe('SubagentService.sendMessage', () => { await handle.agent.whenIdle() await handle.dispose() - const attempt = await sendMessage(ctx, parent, SessionId('plain-child'), message('continue?')) + const attempt = await followup(ctx, parent, SessionId('plain-child'), message('continue?')) const snapshot = await waitTerminal(ctx, attempt.taskId, parent) expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain( @@ -653,9 +685,9 @@ describe('SubagentService.sendMessage', () => { it('derives fallback and bounded labels for resumed activations', async () => { const { ctx, parent } = await setup([]) - const blank = await sendMessage(ctx, parent, SessionId('blank-child'), message(' ')) + const blank = await followup(ctx, parent, SessionId('blank-child'), message(' ')) const longText = 'x'.repeat(100) - const long = await sendMessage(ctx, parent, SessionId('long-child'), message(longText)) + const long = await followup(ctx, parent, SessionId('long-child'), message(longText)) expect(ctx.tasks.get(blank.taskId, parent).label).toBe('subagent follow-up') expect(ctx.tasks.get(long.taskId, parent).label).toBe(`${'x'.repeat(79)}…`) @@ -673,9 +705,9 @@ describe('SubagentService.sendMessage', () => { meta: { parentSession: parent.id }, agentOptions: { provider: 'mock', model: 'mock' }, }) - await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) + await expect(followup(ctx, parent, SessionId('rogue-child'), message('hello'))) .rejects.toThrow(SubagentError) - await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) + await expect(followup(ctx, parent, SessionId('rogue-child'), message('hello'))) .rejects.toThrow(/outside continuation ownership.*not delivered/) await handle.dispose() }) @@ -686,9 +718,10 @@ describe('SubagentService.sendMessage', () => { const { ctx, parent } = await setup([textResponse('quick answer'), textResponse('unused')]) let releaseDispose!: () => void const disposeGate = new Promise((resolve) => { releaseDispose = resolve }) - const realStart = ctx.subagents.start.bind(ctx.subagents) - ctx.subagents.start = async (name, request) => { - const run = await realStart(name, request) + const provider = ctx.subagents.getProvider('spawn')! + const realStart = provider.start.bind(provider) + provider.start = async (request) => { + const run = await realStart(request) const realDispose = run.dispose.bind(run) return { ...run, @@ -716,13 +749,13 @@ describe('SubagentService.sendMessage', () => { // Confirmed steering finds the settled child, fails loud, and does NOT start // a cold resume within this call. - await expect(sendMessage(ctx, parent, started.childId, message('too late?'))) + await expect(followup(ctx, parent, started.childId, message('too late?'))) .rejects.toThrow(/not delivered/) expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId]) releaseDispose() await waitTerminal(ctx, started.taskId, parent) // AFTER the Task settles, retry legitimately starts the next activation. - const retry = await sendMessage(ctx, parent, started.childId, message('retry')) + const retry = await followup(ctx, parent, started.childId, message('retry')) expect(retry.route).toBe('started') await waitTerminal(ctx, retry.taskId, parent) }) @@ -731,7 +764,7 @@ describe('SubagentService.sendMessage', () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) - const followUp = await sendMessage(ctx, parent, started.childId, message('more')) + const followUp = await followup(ctx, parent, started.childId, message('more')) const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' }) expect(() => ctx.tasks.get(followUp.taskId, other)).toThrow(/belongs to another session/) }) @@ -750,7 +783,7 @@ describe('SubagentService.sendMessage', () => { return realLoad(id) } - const followUp = await sendMessage(ctx, parent, started.childId, message('follow up')) + const followUp = await followup(ctx, parent, started.childId, message('follow up')) expect(ctx.tasks.kill(followUp.taskId, parent)).toBe('requested') releaseLoad() const snapshot = await waitTerminal(ctx, followUp.taskId, parent) @@ -772,11 +805,11 @@ describe('SubagentService.sendMessage', () => { return realLoad(id) } - const first = await sendMessage(ctx, parent, started.childId, message('first follow-up')) + const first = await followup(ctx, parent, started.childId, message('first follow-up')) expect(first.route).toBe('started') // The association is installed synchronously, so the competing caller // observes the pending activation instead of starting a duplicate resume. - await expect(sendMessage(ctx, parent, started.childId, message('second follow-up'))) + await expect(followup(ctx, parent, started.childId, message('second follow-up'))) .rejects.toThrow(/not delivered/) releaseLoad() const snapshot = await waitTerminal(ctx, first.taskId, parent) @@ -830,15 +863,21 @@ describe('service disposal with live activations', () => { }) describe('outcome mapping helpers', () => { - it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => { + it.each([ + ['completed', { status: 'completed', output: 'partial' }], + ['aborted', { status: 'killed' }], + ['error', { status: 'failed', detail: 'error' }], + ['max-tokens', { status: 'failed', detail: 'max-tokens' }], + ['refusal', { status: 'failed', detail: 'refusal' }], + ['paused', { status: 'failed', detail: 'paused' }], + ] as const)('settleRun maps the %s stop reason onto its Task outcome', async (stopReason, expected) => { const output = [{ type: 'text' as const, text: 'partial' }] - expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' }) - expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' }) - expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' }) - expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' }) - expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' }) - // Merge-extensible: an unknown reason is failed-with-detail, never success. - expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' }) + await expect(settleRun({ + id: SessionId('child'), + localAgent: undefined, + result: Promise.resolve({ output, stopReason: stopReason as never }), + dispose: () => Promise.resolve(), + })).resolves.toEqual(expected) }) it('settleRun disposes the run before reporting, on both result paths', async () => { diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 90128302c5..9a274abe22 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -12,6 +12,7 @@ import SubagentService, { assertSubagentMaxDepth, type SubagentCapabilities, type SubagentProvider, + type SubagentProviderStartRequest, type SubagentResult, type SubagentRun, type SubagentStartRequest, @@ -37,6 +38,7 @@ function baseRequest(overrides: Partial = {}): SubagentSta class StubProvider implements SubagentProvider { readonly inheritsParentContext = false startCount = 0 + lastRequest: SubagentProviderStartRequest | undefined constructor( readonly name: string, @@ -47,8 +49,9 @@ class StubProvider implements SubagentProvider { }, ) {} - async start(request: SubagentStartRequest): Promise { + async start(request: SubagentProviderStartRequest): Promise { this.startCount += 1 + this.lastRequest = request return { id: SessionId(`child:${this.name}:${request.parent.id}`), localAgent: undefined, @@ -102,27 +105,23 @@ describe('SubagentService', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) }) - it('rejects continuable start and resume when the provider has no resume capability', async () => { + it('keeps provider continuation state out of raw start and exposes no raw resume operation', async () => { const { subagents } = await service() - subagents.registerProvider(new StubProvider('one-shot')) + const provider = new StubProvider('one-shot') + subagents.registerProvider(provider) const descriptor = snapshotSubagentDescriptor({ provider: 'one-shot' }) const sessionId = SessionId('continuable-child') const parent = fakeParent() const signal = new AbortController().signal - await expect(subagents.start('one-shot', baseRequest({ - parent, - signal, + const providerRequest: SubagentProviderStartRequest = { + ...baseRequest({ parent, signal }), continuation: { sessionId, descriptor }, - }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) - await expect(subagents.resume('one-shot', { - sessionId, - prompt: [{ type: 'text', text: 'continue' }], - source: { kind: 'user' }, - parent, - signal, - descriptor, - })).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) + } + await subagents.start('one-shot', providerRequest) + + expect(provider.lastRequest?.continuation).toBeUndefined() + expect('resume' in subagents).toBe(false) }) it('rejects Task-backed continuation operations when their runtime services are absent', async () => { diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index cf3afead31..fc7ab47339 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/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/subagent/tool-subagent-control/README.md -README.md: 0f1eb7a966689d1540c47f41e2f3fa89d2011d2b -README.zh.md: bd140f93f7338a6b1f0e89a285b273080cc5d3cb +README.md: 44fbd44b035ce283e404c491d9fa143a08b71127 +README.zh.md: 3fa1d1e543d1d390975c3aab16504954f283c2f4 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 0f1eb7a966..44fbd44b03 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work. +The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.followup()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work. The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the subagent service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool forwards its execution signal, so cancellation while live delivery awaits admission cancels the shared activation and settles only after the child reaches quiescence. The tool renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered. diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md index bd140f93f7..3fa1d1e543 100644 --- a/packages/subagent/tool-subagent-control/README.zh.md +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -可选的全局具名 `send_message` 工具:`ctx.subagents.sendMessage()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。 +可选的全局具名 `send_message` 工具:`ctx.subagents.followup()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。 本工具不执行生命周期路由。它将每条后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;subagent 服务会保留该来源,并在向运行中激活的现有 Task 在线投递消息与创建新 Task、从持久化存储恢复子 agent 之间做出选择。本工具会转发其执行信号,因此,若在在线投递等待准入期间取消,则会取消共享激活,并仅在子 agent 完全停稳后结算。本工具会渲染实际采用的路由及相关 Task id。投递失败会变为出错的工具结果,并明确说明消息未送达。 diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 3e3bc8eff9..af85457262 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -1,6 +1,6 @@ /** * The globally named `send_message` tool: a thin model-facing adapter over - * `ctx.subagents.sendMessage()`. It performs no lifecycle routing of its + * `ctx.subagents.followup()`. It performs no lifecycle routing of its * own — steer-or-resume orchestration belongs to the subagent service — and it * lives apart from the provider-bound `@deepseek-ai/dsh-tool-subagent` * instances so multiple delegation tools share one control tool. @@ -60,21 +60,23 @@ export function apply(ctx: Context): void { : `message started task ${value.taskId} continuing subagent ${args.subagent_id}`, }], }, - execute(args, exec) { + async execute(args, exec) { const parent = exec.agent if (!parent) { // Non-agent callers have no session to authorize Task access with. throw new Error('send_message requires a calling agent (exec.agent was undefined)') } const message: ContentBlock[] = [{ type: 'text', text: args.message }] - const result = ctx.subagents.sendMessage( + const result = await ctx.subagents.followup( parent, SessionId(args.subagent_id), message, - { kind: 'coordinator', senderSessionId: parent.id }, - exec.signal, + { + source: { kind: 'coordinator', senderSessionId: parent.id }, + signal: exec.signal, + }, ) - return Promise.resolve(result) + return result }, })) } diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index c91c657bac..b035fa1127 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -106,9 +106,9 @@ describe('dsh-tool-subagent-control', () => { // Reach past the tool into the subagent service to fake a running route // deterministically: the tool is a thin adapter, so its steered wording is // what this test pins. - ctx.subagents.sendMessage = async (agent, _childId, message, messageSource) => { + ctx.subagents.followup = async (agent, _childId, message, options) => { steered = (message[0] as { text: string }).text - source = messageSource + source = options.source return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } } const result = await callTool(ctx, 'send_message', { @@ -130,9 +130,9 @@ describe('dsh-tool-subagent-control', () => { }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const deliveryStarted: PromiseWithResolvers = Promise.withResolvers() - const sendMessage = ctx.subagents.sendMessage.bind(ctx.subagents) - ctx.subagents.sendMessage = (agent, childId, message, source, signal) => { - const delivery = sendMessage(agent, childId, message, source, signal) + const followup = ctx.subagents.followup.bind(ctx.subagents) + ctx.subagents.followup = (agent, childId, message, options) => { + const delivery = followup(agent, childId, message, options) deliveryStarted.resolve() return delivery } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c8aeb86a39..f7ab385afd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -163,9 +163,11 @@ export const LINK_MAP: Readonly> = { ContinuableStart: 'subagent.md', ContinuableStartSpec: 'subagent.md', CoordinatorMessageSource: 'subagent.md', - SendMessageResult: 'subagent.md', + SubagentFollowupOptions: 'subagent.md', + SubagentFollowupResult: 'subagent.md', SubagentProvider: 'subagent.md', - SubagentResumeRequest: 'subagent.md', + SubagentProviderResumeRequest: 'subagent.md', + SubagentProviderStartRequest: 'subagent.md', SubagentRun: 'subagent.md', SubagentService: 'subagent.md', SubagentStartRequest: 'subagent.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 10c652f704..0a17861a07 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1094,6 +1094,11 @@ "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentProviderStartRequest", + "source": "packages/subagent/subagent/src/types.ts" + }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentContinuation", @@ -1106,7 +1111,17 @@ }, { "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentResumeRequest", + "symbol": "SubagentFollowupOptions", + "source": "packages/subagent/subagent/src/continuation.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentFollowupResult", + "source": "packages/subagent/subagent/src/continuation.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentProviderResumeRequest", "source": "packages/subagent/subagent/src/types.ts" }, { From 264bc41a13cc24d66e1d5b2b275854b7f99331d5 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 28 Jul 2026 11:19:22 +0800 Subject: [PATCH 047/114] fix(subagent): preserve ordinary start requests --- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/subagent.i18n.yaml | 4 ++-- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/subagent/subagent/src/index.ts | 6 ++---- .../subagent/subagent/tests/service.spec.ts | 20 +++++++------------ 7 files changed, 15 insertions(+), 23 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ca1d6f75fc..a1ff16cb7e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2005,7 +2005,7 @@ list(): string[] * @param request - child prompt, parent, signal, and optional capabilities. * @returns the ready holder-owned run. */ -async start(name: string, request: SubagentStartRequest): Promise +async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise ``` Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentFollowupResult](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 6fd7de2f74..a535c7ab81 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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/subagent.md -subagent.md: 2dc25dfb14b1506edf7f53f6ce0d8681fefa98c6 -subagent.zh.md: 00f2748ad92ae37b0a2fe2616d9e052f9c4b916f +subagent.md: 8f24afec47a970711aae49cae6b3535b9f532e5f +subagent.zh.md: 50c5cb887ef814c074a85fc4fee9cd2fe85d685c diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 2dc25dfb14..8f24afec47 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -94,7 +94,7 @@ interface SubagentStartRequest { `signal` is the single cancellation channel before and after readiness. The [subagent composition-controls Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the persona, live global-tool filter, absolute-depth, and visibility-not-authority rationale. -Providers receive a separate resolved shape. Raw `SubagentService.start()` clears continuation state, while `startContinuable()` alone supplies the service-allocated identity and descriptor. +Providers receive a separate resolved shape. The `SubagentService.start()` parameter type excludes continuation state, while `startContinuable()` alone supplies the service-allocated identity and descriptor. ```ts type-equiv /** diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 00f2748ad9..50c5cb887e 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -94,7 +94,7 @@ interface SubagentStartRequest { `signal` 是就绪前后唯一的取消通道。[subagent 组合控制 Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)规定 persona、live 全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 -提供方会接收单独的已解析请求类型。直接调用 `SubagentService.start()` 会清除继续执行状态;只有 `startContinuable()` 才会提供由服务分配的标识和描述符。 +提供方会接收单独的已解析请求类型。`SubagentService.start()` 的参数类型不包含继续执行状态;只有 `startContinuable()` 才会提供由服务分配的标识和描述符。 ```ts type-equiv /** diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 224bf25e0b..6acad85629 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -905,7 +905,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * List registered provider names in insertion order.\n * @returns the registered names.\n */', }, { - signature: 'async start(name: string, request: SubagentStartRequest): Promise', + signature: 'async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise', jsDoc: '/**\n * Establish a ready child on the named provider. Capability and semantic\n * checks run before delegation. Provider ownership lasts until its promise\n * fulfills; a rejection therefore has no run for the caller to dispose and\n * emits no run lifecycle events.\n * @param name - the provider to use.\n * @param request - child prompt, parent, signal, and optional capabilities.\n * @returns the ready holder-owned run.\n */', }, ], diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 508388e4f5..023ac1fe26 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -298,10 +298,8 @@ export class SubagentService extends Service { * @param request - child prompt, parent, signal, and optional capabilities. * @returns the ready holder-owned run. */ - async start(name: string, request: SubagentStartRequest): Promise { - // A provider request is structurally assignable to the caller shape. Clear - // its wider field so only startContinuable can supply service-owned state. - return this.startProvider(name, { ...request, continuation: undefined }) + async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise { + return this.startProvider(name, request) } /** Validate and dispatch one ordinary or service-resolved provider start. */ diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 9a274abe22..e86de737ed 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { type Agent } from '@deepseek-ai/dsh-agent' @@ -105,22 +105,16 @@ describe('SubagentService', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) }) - it('keeps provider continuation state out of raw start and exposes no raw resume operation', async () => { + it('borrows ordinary start requests and exposes no provider continuation operations', async () => { const { subagents } = await service() const provider = new StubProvider('one-shot') subagents.registerProvider(provider) - const descriptor = snapshotSubagentDescriptor({ provider: 'one-shot' }) - const sessionId = SessionId('continuable-child') - const parent = fakeParent() - const signal = new AbortController().signal + const request = baseRequest() + await subagents.start('one-shot', request) - const providerRequest: SubagentProviderStartRequest = { - ...baseRequest({ parent, signal }), - continuation: { sessionId, descriptor }, - } - await subagents.start('one-shot', providerRequest) - - expect(provider.lastRequest?.continuation).toBeUndefined() + expect(provider.lastRequest).toBe(request) + expectTypeOf() + .not.toExtend[1]>() expect('resume' in subagents).toBe(false) }) From bb6e6d6f3b832bcb17e213d4cc32c60f565985f6 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 1 Aug 2026 08:37:21 +0800 Subject: [PATCH 048/114] fix(subagent): preserve continuable behavior after rebase --- apps/cli/config/core-web.cordis.yml | 3 + apps/cli/tests/shipped-composition.e2e.ts | 1 + apps/web/tests/shipped-composition.e2e.ts | 1 + docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.zh.md | 20 +-- .../acp-agent/depth-two.cordis.snapshot.yml | 1 + examples/acp-agent/depth-two.cordis.yml | 1 + .../code-mode-workspace-context/session.jsonl | 4 +- .../tests/snapshots/skill-load/session.jsonl | 4 +- .../snapshots/workspace-context/session.jsonl | 4 +- .../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/core/agent-loop/src/agent.ts | 2 + packages/core/agent-loop/tests/loop.spec.ts | 135 +++++++++++++++++- packages/core/agent/README.i18n.yaml | 4 +- .../goal-session/tests/goal-session.spec.ts | 31 +--- .../subagent/subagent-fork/README.i18n.yaml | 4 +- packages/subagent/subagent-fork/README.zh.md | 1 - .../subagent/subagent-spawn/README.i18n.yaml | 4 +- packages/subagent/subagent-spawn/README.zh.md | 1 - 22 files changed, 173 insertions(+), 60 deletions(-) diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index d025aef7f4..2a5205cd0d 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -25,6 +25,9 @@ - id: plan-mode disabled: true +- id: tool-subagent-control + disabled: true + - id: tool-subagent disabled: true diff --git a/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts index b6c5adba06..8371f795c8 100644 --- a/apps/cli/tests/shipped-composition.e2e.ts +++ b/apps/cli/tests/shipped-composition.e2e.ts @@ -35,6 +35,7 @@ const EXPECTED_TUI_TOOLS = [ 'get_goal', 'ralph', 'read', + 'send_message', 'skill', 'str_replace_editor', 'subagent', diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 3f5ce4e4fa..a9162b631a 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -30,6 +30,7 @@ const EXPECTED_TOOLS = [ 'get_goal', 'ralph', 'read', + 'send_message', 'skill', 'str_replace_editor', 'subagent', diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index b1bdfb1a14..a156394d97 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: 70c63d8a84963f56468b5fcdacb580a798de2e10 -core.zh.md: 280529b7de6c3e8546b71791367710c588ad9282 +core.md: 795256a2b30e44771baf8bcb7c1d541134692a02 +core.zh.md: 5cebff049aae53df9f0494da1fadc8dfa5d9ad09 diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 280529b7de..5cebff049a 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -568,6 +568,8 @@ interface CancelOptions { } ``` +`SteeringReceipt.outcome` 始终会解析。`admitted` 标识其不可变请求历史包含该确切消息的轮次与步骤;`rejected` 表示生命周期或终止策略先丢弃了该消息。同步输入校验仍会从 `steer()` 抛出异常。 + ```ts type-equiv /** Stable runtime cause accepted by {@link Agent.cancel}. */ type AgentCancelCause = @@ -669,16 +671,18 @@ interface Agent { followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn — the - * `next-step`/wakeup preset of {@link send}. It stages for the next steering - * checkpoint before a request or stop decision. If the activity fails before - * that boundary, the remainder stays staged without waking the agent; retry - * or a later prompt takes it. Outside that window steering falls back to a - * woken follow-up turn, while cancellation or disposal may discard pending - * steering. + * Submit steering with a message-owned admission receipt — the + * `next-step`/wakeup preset of {@link send}. During prompt admission or an + * open turn, the message waits in the steering FIFO until a committed step + * snapshots it; outside that window it enters the ordinary queued FIFO. The + * receipt resolves `admitted` only after the message joins that step's + * immutable request history, or `rejected` when terminal policy, + * cancellation, or disposal discards it first. A non-terminal turn close may + * leave it staged for a later admitted prompt without settling the receipt. * @param message - identified steering content and its producer provenance. + * @returns the receipt for this exact message's eventual admission outcome. */ - steer(message: UserMessage): void + steer(message: UserMessage): SteeringReceipt /** * Append model-facing context without running the model — the diff --git a/examples/acp-agent/depth-two.cordis.snapshot.yml b/examples/acp-agent/depth-two.cordis.snapshot.yml index 4e849d7835..3e292699d1 100644 --- a/examples/acp-agent/depth-two.cordis.snapshot.yml +++ b/examples/acp-agent/depth-two.cordis.snapshot.yml @@ -23,6 +23,7 @@ config: provider: spawn toolName: subagent + backgroundMode: continuable maxDepth: 2 # Re-pin the recorded model: cordis.yml ships deepseek-v4-pro, but this # scenario's corpus was captured on flash. A config patch replaces the diff --git a/examples/acp-agent/depth-two.cordis.yml b/examples/acp-agent/depth-two.cordis.yml index 25b0ee8e38..1af96e9283 100644 --- a/examples/acp-agent/depth-two.cordis.yml +++ b/examples/acp-agent/depth-two.cordis.yml @@ -10,4 +10,5 @@ config: provider: spawn toolName: subagent + backgroundMode: continuable maxDepth: 2 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 d264b20da8..4588ebb898 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 @@ -2,8 +2,8 @@ {"type":"turn/start","seq":0,"time":1785014475014,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"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":"\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":"c9aaa351-f7e6-40ef-955a-c5b8ee07667f"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464674590,"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":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} +{"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":"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"}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 6b366448f1..81703a533d 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,8 +2,8 @@ {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"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":"\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":"021a7fcb-3d54-4ed9-8c2c-ca7565599fd8"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464638477,"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":"22b51be2-4727-4990-96cd-7017c137152e"},"surfaceOp":"append"} +{"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":"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 339ba5480d..0a568d3460 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -2,8 +2,8 @@ {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"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":"c95810d8-2b1e-42b9-9d81-82269ddb0035"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464650864,"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":"6dd61dad-f320-4dda-a481-63ee420df9af"},"surfaceOp":"append"} +{"type":"user/message","seq":3,"time":1784903339799,"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":"c95810d8-2b1e-42b9-9d81-82269ddb0035"},"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","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"}} 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 70ddefa5ea..ee0434e48c 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"85750b5e-389a-4dfb-83e7-3341025692da"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 route: \"steered\" | \"started\";\n taskId: 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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":1783957884564,"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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681625,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"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 3d58a9067a..3cb4cabd02 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2b9d695a-5ba1-4520-8130-d618bc1a4743"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 route: \"steered\" | \"started\";\n taskId: 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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":1783957884701,"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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681788,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"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 39d437b56c..d1a5852ea4 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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 route: \"steered\" | \"started\";\n taskId: 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: 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 subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"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 076ee192f6..28a652be89 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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","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 continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"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/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index b41dd69594..90354f0cc2 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -71,6 +71,7 @@ function createSteeringDelivery(): SteeringDelivery { return { receipt: { outcome: promise }, settle(outcome): void { + /* v8 ignore next -- each ownership transfer removes the delivery before another settlement path can reach it. */ if (settled) return settled = true resolve(outcome) @@ -544,6 +545,7 @@ export class ReactLoopAgent implements Agent { this.drainOutbox(turn) break steps } + /* v8 ignore next -- step() folded the same steering predicate into continueTurn immediately before returning. */ if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue break case 'request-failed': { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 725e3db36a..15bfa7e0fc 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -470,7 +470,10 @@ describe('agent loop', () => { parameters: {}, async execute() { // steer while the turn is running (during tool execution) - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } })) + agent.send( + createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }), + { target: 'next-step', wakeup: true }, + ) return [{ type: 'text', text: 'tool done' }] }, })) @@ -544,6 +547,120 @@ describe('agent loop', () => { expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering') }) + it('rejects failed steering commits while preserving later context', async () => { + const adapter = new MockAdapter([textResponse('recovered')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('failed-steering-commit'), { provider: 'mock', model: 'mock' }) + let receipt: ReturnType | undefined + ctx.on('agent/step', (subject) => { + if (subject !== agent || receipt !== undefined) return + receipt = subject.steer(createUserMessage({ + content: [{ type: 'text', text: 'rejected steering' }], + source: { kind: 'user' }, + })) + subject.inject(createUserMessage({ + content: [{ type: 'text', text: 'preserved context' }], + source: { kind: 'plugin', plugin: 'loop-test' }, + })) + }) + let rejected = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as { type: string } + if (event.type === 'steering/message' && !rejected) { + rejected = true + throw new Error('reject steering commit') + } + }) + + send(agent, 'first prompt') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(0) + if (receipt === undefined) throw new Error('agent/step did not submit steering') + expect(await receipt.outcome).toEqual({ status: 'rejected' }) + expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) + + send(agent, 'recover') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(1) + const request = JSON.stringify(adapter.requests[0]?.messages) + expect(request).toContain('preserved context') + expect(request).not.toContain('rejected steering') + }) + + it('rejects committed steering when the step boundary fails', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('failed-step-boundary'), { provider: 'mock', model: 'mock' }) + let receipt: ReturnType | undefined + ctx.on('agent/step', (subject) => { + if (subject !== agent || receipt !== undefined) return + receipt = subject.steer(createUserMessage({ + content: [{ type: 'text', text: 'committed steering' }], + source: { kind: 'user' }, + })) + }) + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as { type: string } + if (event.type === 'step/start') throw new Error('reject step boundary') + }) + + send(agent, 'prompt') + await waitForIdle(ctx, agent) + + if (receipt === undefined) throw new Error('agent/step did not submit steering') + expect(await receipt.outcome).toEqual({ status: 'rejected' }) + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true) + expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) + }) + + it('retries context and steering after a context commit fails', async () => { + const adapter = new MockAdapter([textResponse('recovered')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('failed-context-commit'), { provider: 'mock', model: 'mock' }) + let receipt: ReturnType | undefined + ctx.on('agent/step', (subject) => { + if (subject !== agent || receipt !== undefined) return + subject.inject(createUserMessage({ + content: [{ type: 'text', text: 'preserved context' }], + source: { kind: 'plugin', plugin: 'loop-test' }, + })) + receipt = subject.steer(createUserMessage({ + content: [{ type: 'text', text: 'preserved steering' }], + source: { kind: 'user' }, + })) + }) + let rejected = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as { type: string; data?: { source?: { kind: string } } } + if (event.type === 'user/message' && event.data?.source?.kind === 'plugin' && !rejected) { + rejected = true + throw new Error('reject context commit') + } + }) + + send(agent, 'first prompt') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) + + send(agent, 'recover') + await waitForIdle(ctx, agent) + + if (receipt === undefined) throw new Error('agent/step did not submit steering') + expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 2, step: 1 }) + expect(adapter.requests).toHaveLength(1) + const request = JSON.stringify(adapter.requests[0]?.messages) + expect(request).toContain('preserved context') + expect(request).toContain('preserved steering') + }) + it('inject() while idle appends context without opening a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -722,12 +839,22 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let receipt: ReturnType | undefined + let contextInjected = false + ctx.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'step/end' || contextInjected) return + contextInjected = true + agent.inject(createUserMessage({ + content: [{ type: 'text', text: 'final context' }], + source: { kind: 'plugin', plugin: 'finalize' }, + })) + }) ctx.tools.register(defineContentToolFixture({ name: 'finalize', description: '', parameters: {}, async execute(_args, exec) { - // Steering lands while the concluding tool is still executing. + // Steering lands while the concluding tool is still executing; the + // step/end listener adds ordinary context after the normal result drain. receipt = agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } })) exec.concludeTurn() return [{ type: 'text', text: 'final' }] @@ -744,6 +871,9 @@ describe('agent loop', () => { if (receipt === undefined) throw new Error('concluding tool did not submit steering') expect(await receipt.outcome).toEqual({ status: 'rejected' }) expect(events).not.toContain('steering/message') + expect(agent.session.events.some(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.content.some(block => block.type === 'text' && block.text === 'final context'))).toBe(true) send(agent, 'follow up') await waitForIdle(ctx, agent) @@ -752,6 +882,7 @@ describe('agent loop', () => { .flatMap(message => message.content) .filter(block => block.type === 'text') .map(block => block.text) + expect(texts).toContain('final context') expect(texts).not.toContain('late steering') }) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index e94f507b99..78473df4a7 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/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/core/agent/README.md -README.md: 8799bc3664b2137b386b752f905e1414fb770cb9 -README.zh.md: 851c174ba80bebbab8ee1255cb04be4bcec7eabd +README.md: 8a6028352127c4638c0b5e0e3ee85964d1d7d734 +README.zh.md: ffa71ea987ab355ff2f30b6164376199cd5d0170 diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 073cb5002f..5a3f0bdca2 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -9,7 +9,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal' import { createUserMessage, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { TurnEndReason } from '@deepseek-ai/dsh-session' import * as goalSession from '../src/index.ts' declare module '@deepseek-ai/dsh-session' { @@ -787,35 +787,6 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(1) }) - it('yields to a round whose turn/end never committed instead of misreading it as settled', async () => { - const test = await harness([textResponse('round ran')]) - // A persistent pre-commit turn/end rejection reaches idle with the - // attempt's turn open and no terminal reason. The driver must yield - // instead of clearing the reservation or scheduling another round. - let roundTurn: number | undefined - test.ctx.on('internal/dispatch', (_mode, name, args) => { - if (name !== 'session/event') return - const event = args[1] as SessionEvent - if (event.type === 'turn/start' && event.data.trigger.kind === 'message' - && event.data.trigger.source.kind === 'goal') { - roundTurn = event.data.turn - } - if (event.type === 'turn/end' && event.data.turn === roundTurn) { - throw new Error('turn close permanently rejected') - } - }) - test.ctx.goals.create(test.agent, { objective: 'survive a lost turn end' }) - await waitForRequests(test.adapter, 1) - await test.agent.whenIdle() - await new Promise((resolve) => { setImmediate(resolve) }) - - expect(test.adapter.requests).toHaveLength(1) - expect(test.ctx.goals.get(test.agent)).toMatchObject({ - phase: 'active', - activation: 'armed', - }) - }) - it('cancels an accepted queued round and awaits its driver task during teardown', async () => { const test = await harness([]) let unloading: Promise | undefined diff --git a/packages/subagent/subagent-fork/README.i18n.yaml b/packages/subagent/subagent-fork/README.i18n.yaml index 14b40beb88..317762e160 100644 --- a/packages/subagent/subagent-fork/README.i18n.yaml +++ b/packages/subagent/subagent-fork/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/subagent/subagent-fork/README.md -README.md: b448dc309bff07c744443530a648c7c30e4d20d9 -README.zh.md: 3e14206d5637fded9edb4e173608c55e3341f8fc +README.md: 55475aee7841e91960de79887dfe9bf37afdf9da +README.zh.md: 3eec8cb51a47243a1f06416a3f8f99ae8df8e734 diff --git a/packages/subagent/subagent-fork/README.zh.md b/packages/subagent/subagent-fork/README.zh.md index 3e14206d56..3eec8cb51a 100644 --- a/packages/subagent/subagent-fork/README.zh.md +++ b/packages/subagent/subagent-fork/README.zh.md @@ -57,5 +57,4 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随 ## 已知限制与暂缓事项 -- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。 - **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。 diff --git a/packages/subagent/subagent-spawn/README.i18n.yaml b/packages/subagent/subagent-spawn/README.i18n.yaml index 970a148213..00eb8d457f 100644 --- a/packages/subagent/subagent-spawn/README.i18n.yaml +++ b/packages/subagent/subagent-spawn/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/subagent/subagent-spawn/README.md -README.md: 868f829edbcfe2eb4d66ccd0ff9988924c70298b -README.zh.md: 99cfdf0e633345d1152c59cbe5ce7a029eb6ec9d +README.md: 811f19e6e68362bd14e75d0a9059ee61fda3f015 +README.zh.md: 2b189f77c4ff63ca026f472187a55c68def18ea1 diff --git a/packages/subagent/subagent-spawn/README.zh.md b/packages/subagent/subagent-spawn/README.zh.md index 99cfdf0e63..2b189f77c4 100644 --- a/packages/subagent/subagent-spawn/README.zh.md +++ b/packages/subagent/subagent-spawn/README.zh.md @@ -52,5 +52,4 @@ spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: ## 已知限制与暂缓事项 -- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。 - **全新表示不含父 agent transcript(文本记录)**:子 agent 会继承 cwd、谱系、模型及显式配置的 persona/工具限制,但不继承父 agent 的任何对话;需要已完成轮次上下文时,请使用 fork 提供方。 From 26a117f842c9cc87987963a2cd127b2c980e283c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:26:13 +0800 Subject: [PATCH 049/114] feat(subagent): activation-based continuable subagents (source) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Task-backed continuation manager with one durable Session plus at most one process-local Activation — a residency epoch for a reconstructed child Agent, not a request, result, cancellation, or Task boundary. The manager owns activation admission, authority, the live ownership graph, cold resume, and child-first disposal; the Agent inbox is the only turn FIFO. - startContinuable() is async and returns { childId, messageId } at inbox acceptance; followup() takes a SubagentAuthority and returns AgentMessageId. - SubagentProvider.resume?(), SubagentProviderResumeRequest, SubagentRun.steer?(), SubagentProviderStartRequest and SubagentContinuation are deleted; prepareContinuable?() is the continuable-creation capability. - Cold resume calls ctx.agents.resume() from the manager through a private activation-owner scope, never dispatching through a provider. - Extract shared child composition, descriptor seeding, depth accounting, and one-shot run settlement so the manager and one-shot driver keep one home per fact. Tests and docs follow in subsequent commits. --- ...ntinuable-subagent-conversations.i18n.yaml | 6 + ...7-28-continuable-subagent-conversations.md | 216 ++++ ...8-continuable-subagent-conversations.zh.md | 216 ++++ packages/subagent/subagent-fork/src/index.ts | 20 +- .../subagent/subagent-inprocess/src/index.ts | 220 +--- packages/subagent/subagent-spawn/src/index.ts | 16 +- packages/subagent/subagent/src/child-agent.ts | 128 ++ .../subagent/subagent/src/continuation.ts | 1114 ++++++++++------- packages/subagent/subagent/src/depth.ts | 51 + .../subagent/subagent/src/descriptor-seed.ts | 31 + packages/subagent/subagent/src/index.ts | 254 ++-- .../subagent/subagent/src/run-settlement.ts | 71 ++ packages/subagent/subagent/src/types.ts | 162 +-- .../tool-subagent-control/src/index.ts | 37 +- packages/subagent/tool-subagent/src/index.ts | 47 +- 15 files changed, 1721 insertions(+), 868 deletions(-) create mode 100644 .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md create mode 100644 .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md create mode 100644 packages/subagent/subagent/src/child-agent.ts create mode 100644 packages/subagent/subagent/src/depth.ts create mode 100644 packages/subagent/subagent/src/descriptor-seed.ts create mode 100644 packages/subagent/subagent/src/run-settlement.ts diff --git a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml new file mode 100644 index 0000000000..4ef20ef978 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.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/proposed/feature/2026-07-28-continuable-subagent-conversations.md +2026-07-28-continuable-subagent-conversations.md: 3902fbc33004219f98d070d4b898de6b2c19d40d +2026-07-28-continuable-subagent-conversations.zh.md: 11f59d8f1a57e2d1bf375a3e1c1cd46043c60a3f diff --git a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md new file mode 100644 index 0000000000..3902fbc330 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md @@ -0,0 +1,216 @@ +# Agent Note: Continuable subagents + +Status: proposed + +English | [中文](2026-07-28-continuable-subagent-conversations.zh.md) + +This proposal would replace the Task-backed continuation manager from [Continuable background subagents](../../implemented/feature/2026-07-21-continuable-background-subagents.md). It retains the single `ctx.subagents` service from [Merge subagent control into the subagent service](../../implemented/simplification/2026-07-26-merge-subagent-control-service.md) and the intent-named `followup` operation from [Intent-named subagent continuation operations](../../implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md). + +## Problem + +The continuation manager currently makes one Task, one provider execution, and one result boundary the same object lifetime. Task settlement disposes the child Agent, Task completion injects the completion notice, and later input reconstructs another Agent. This couples a generic background-work abstraction to conversation delivery even though a continuable subagent already has a Session and an Agent inbox. + +Giving queued parent requests to the continuation manager and user messages to the Agent creates two FIFOs with no single ordering authority. Giving both to Tasks instead duplicates the Agent loop's admission, cancellation, and quiescence machinery. `Agent.whenIdle()` cannot recover a per-request Task result because one running interval may drain multiple queued turns, and broad `Agent.cancel()` cannot remove one queued request exactly. + +The runtime lifetime is also wider than one turn. A subagent can finish its own turn while a child it created is still running. Disposing the parent runtime at that point removes the Agent that still owns descendant teardown. Keeping every historical subagent resident instead would make memory use unbounded. + +Users and parent Agents also need to send later work to the same live child without changing its current turn. Queueing every continuation message as a follow-up preserves one ordering rule for both senders. + +## Proposal + +A continuable subagent has one durable Session and at most one process-local Activation: + +```text +persisted Session + -> optional live Activation + -> one retained AgentHandle + -> Agent inbox as the only turn FIFO + -> zero or more owned child Activations +``` + +An Activation is one residency epoch for a reconstructed child Agent. It may execute multiple FIFO turns and remain resident while waiting for descendants. It is not a request, result, cancellation, or Task boundary. + +The continuation manager owns activation admission, authority checks, the live ownership graph, cold resume, and child-first disposal. The Agent loop owns all turn ordering and execution. The proposal creates no Task for a continuable subagent, no Activation FIFO, and no queued Activation state. + +### Materialization and public operations + +The named subagent provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its optional `prepareContinuable(request): Promise` method is the continuable-creation capability. The returned spec contains only detached provider-specific creation inputs such as the optional parent-history seed; it contains no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. The manager reserves the child identity, resolves the durable descriptor and common Agent setup, calls `ctx.agents.create()` through a private activation-owner scope, installs the returned `AgentHandle` into the Activation, establishes any continuable-parent ownership, and then calls `Agent.followup(initialPrompt)`. Inbox acceptance yields an `AgentMessageId`; at that boundary `ctx.subagents.startContinuable()` returns `{ childId, messageId }` without waiting for the turn to start or for the message to enter the Session log. + +Any failure before inbox acceptance rejects without returning either id. Agent creation provides rollback before handle transfer; after transfer, the manager disposes the created handle, removes the Activation, and rolls back any parent `ownedChildren` membership before rejecting. + +`backgroundMode: 'one-shot' | 'continuable'` remains deployment policy. Configured continuable mode requires `prepareContinuable`; method presence replaces `SubagentProvider.resume?()` as the capability check, while a capable provider may still run one-shot work. + +Cold resume does not dispatch through a subagent provider. The continuation manager folds the generic in-process descriptor, calls `ctx.agents.resume()` through the same activation-owner scope, installs the returned `AgentHandle`, and submits the waiting `next-turn`. `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent, and the initial provider name is not a recovery capability; remote providers require a separate design. + +`SubagentProvider.start()` and `SubagentRun` remain exclusively on the unchanged one-shot path. A continuable Activation directly owns its `AgentHandle` and never creates, wraps, or retains a `SubagentRun`; `SubagentRun.steer?()` is therefore absent. + +`ctx.subagents.followup(authority, childId, content, { source, signal })` remains the sole continuation-message operation. `authority` is either `{ kind: 'parent', agent }` or `{ kind: 'user' }`; the parent variant is admitted only from an exact live Agent tool context, while only a trusted host adapter can supply user authority. `source` remains durable provenance and grants no authority. The model-facing `send_message` tool keeps only its stable `subagent_id` and `message` fields and always submits a follow-up turn. Both start and follow-up return the accepted `AgentMessageId`, and neither reports how the manager materialized the Activation. + +For start and follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance. After the operation returns its `AgentMessageId`, the manager owns the Activation independently; later caller cancellation does not cancel the accepted turn or dispose the child. + +### Durable Session and live Activation + +The Session owns the stable child identity, transcript, direct-parent lineage, delegation depth, and versioned continuation descriptor. `SessionHeader.parentSession` is durable provenance and an authorization input; it is not a live routing capability and does not imply that the historical parent is resident. + +An idle historical Session has no `AgentHandle`. The first authorized `next-turn` delivery resumes an Activation from the persisted Session and submits the message to its inbox. A user-authorized cold resume does not load the historical parent Agent. A parent-originated resume uses the exact live parent Agent for authorization and, when that parent has an Activation, ownership; it never uses the parent for reconstruction. + +The Activation directly owns the published `AgentHandle` until it settles, while the manager's private activation-owner scope is its structural Cordis owner. The continuable path creates no intermediate result-bearing execution wrapper, including `SubagentRun`; one-shot delegation remains unchanged and outside this lifecycle. Remote providers are outside the MVP and require a separate Activation ownership contract when introduced. Historical Sessions consume no runtime memory after their Activation is disposed. + +### Activation lifecycle + +The public lifecycle has three states and no `queued` state: + +```text +running + | Agent quiescent with live children + v +waiting + | next-turn + +--------------------------> running + +running or waiting + | Agent quiescent and no live children + v +settled + | AgentHandle.dispose completes + v +no Activation +``` + +`running` means the Agent has an active admission or turn, or its inbox contains waking work. `waiting` means the Agent is quiescent but the Activation still owns at least one child Activation that has not completed disposal. `settled` means the Agent is quiescent and every owned child is disposed; the manager then disposes the `AgentHandle` and removes the Activation. + +The manager derives these states from Agent quiescence and the owned-child set rather than maintaining a second execution state machine. A `next-turn` delivered while `running` joins the Agent inbox. A `next-turn` delivered while `waiting` wakes the same Agent and returns the Activation to `running`. Delivery after disposal cold-resumes a new Activation. + +The manager linearizes delivery, child release, and disposal for each durable child. If a delivery races with final disposal, exactly one side wins the admission cutoff: delivery either enters the still-live Agent inbox, or waits for disposal and cold-resumes a new Activation. No caller can send to a handle after its disposal transaction begins. + +### One inbox and follow-up delivery + +The Agent inbox is the only queue. Every continuation message uses `Agent.followup()` and becomes one FIFO turn; neither the continuation manager nor the host maintains another message queue. Every accepted waking item keeps the current Activation live until `Agent.whenIdle()` observes the complete waking suffix. + +Routing depends only on Activation residency: + +| Activation state | Sender | `followup` | +|---|---|---| +| `running` | parent or user | enqueue in the same Activation | +| `waiting` | parent or user | wake the same Activation | +| no Activation | parent or user | cold-resume a new Activation | + +The continuation layer defines no separate delivery-route result. Successful `ctx.subagents.followup()` and `send_message` delivery returns the accepted `AgentMessageId`, while delivery failure throws. Existing `agent/inbox/enqueue`, `agent/inbox/dequeue`, and `agent/inbox/discard` events remain the message-lifecycle observations; adapters may render a generic acceptance but do not expose `started`, `queued`, `resumed`, or another subagent-specific route vocabulary. + +### Child ownership + +Every Activation owns its `AgentHandle` and an `ownedChildren: Set`. Because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. `SessionHeader.parentSession` records the durable direct-parent identity, while membership in `ownedChildren` records the process-local ownership relationship. + +When the authenticated parent is itself a continuation-managed Activation, starting a child or submitting parent-originated work adds the child Session id to that parent's `ownedChildren` before the child can run or the message can enter its inbox. That parent cannot settle or dispose while this set is non-empty. A top-level or other non-continuation Agent has no Activation and does not join this waiting graph. + +Child release occurs only after the child Agent is quiescent, every child of that child is disposed, the final durability checkpoint settles, and the child's `AgentHandle` completes disposal. The manager calls `ctx.sessions.flush(child.session)`: `true` confirms durability, while `false` or rejection is normalized to `DURABILITY_FAILED`. A failed checkpoint is reported but does not prevent handle disposal or ownership release, because retaining a failed child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`; a user-resumed child with no live owner has nothing to release. Manager teardown uses the same child-first order. + +A user cold-resume creates an Activation without adding it to the historical parent's `ownedChildren`. If the direct parent later submits work to that live Activation and is itself continuation-managed, admission establishes ownership before enqueueing the message; a non-continuation parent remains outside the waiting graph. + +The MVP retains ownership until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. + +Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain and includes user-resumed Activations without live owners. + +The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. The manager snapshots the live roots after closing admission, stops its outward lifecycle notifications before cancellation, and retains its internal ownership bookkeeping until every handle settles. Each Activation has one memoized disposal promise so host shutdown, manager unload, child release, and normal settlement can converge without double release. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining handles, and the aggregate drain reports failure after all branches settle. Durable child Sessions survive this process-local teardown. + +### Deferred report delivery + +The MVP exposes no `report` tool and provides no child-to-parent content delivery or automatic parent wakeup. The durable child Session remains the source of the child's detailed output. + +A later proposal may add an ordinary model-facing `report(output)` tool that can be called zero or multiple times in one turn. Its delivery policy may distinguish quiet parent injection from waking the parent; recipient selection, acknowledgement, durability, and retry semantics are deferred with that tool. Adding report delivery does not require another Activation state or execution queue. + +### Deferred steering + +The MVP exposes no subagent steering operation. Parent and user continuation messages always open later FIFO turns, so the continuation layer stores no current-turn controller and adds no controller-aware Agent admission seam. + +A later host UI may expose separate **Steer** and **Follow up** actions. User steering would be strict and live-only: it may call the existing Agent steering path only while the Activation accepts a next step, must reject otherwise, and must never fall back to queueing or cold resume. Exposing parent steering to a model-facing tool remains a separate design because distinct tool names express intent but do not establish whether the parent may modify a user-controlled turn. + +### Authority and provenance + +Authority is supplied by a trusted host interaction or an exact live Agent tool context. `MessageSource` and `senderSessionId` are durable provenance after admission, not caller-controlled authority. + +The MVP authorizes the host user and the durable child's direct parent. Parent authorization checks `SessionHeader.parentSession` against the authenticated parent Agent before registering the child in that parent's `ownedChildren`. Other Agents, ancestors, teams, and workflows remain rejected until an explicit authority protocol exists. + +User authority may cold-resume a child without its parent. Parent-originated delivery requires the parent to be live when admitted and keeps it live through the ownership relationship. + +### Durability, disposal, and recovery + +Without Tasks there is no `task_output`, `task_kill`, Task status, per-message result promise, or public subagent cancellation operation. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, neither parent nor user can cancel the message, turn, or Activation through `ctx.subagents`; `Agent.cancel()` remains a lower-level Agent capability that this MVP does not expose through the subagent service. + +Host and manager teardown remains the lifecycle-wide stop path. It closes admission, disposes every live Activation forest child-first, and preserves the durable Sessions. + +Each turn requests the Session durability checkpoint, and final Activation settlement requires the manager to inspect `ctx.sessions.flush()` rather than ignore its boolean result. `true` confirms that at least one durability listener participated and every listener settled successfully. `false` or rejection reports `DURABILITY_FAILED`; normal background settlement logs the lifecycle failure, while an explicit host or manager drain includes it in the aggregate rejection after all branches settle. Either way, the manager still disposes the handle and releases ownership, and the persisted child state may be missing or stale on a later resume. + +Only messages written to the child Session log are reconstructable with their admitted provenance; inbox acceptance alone provides no restart guarantee. + +Session and descriptor persistence survive restart. Activation state, Agent inbox contents, and the ownership graph are process-local. A process crash may lose an accepted initial prompt or follow-up that remained in the inbox without reaching the Session log. The Session and descriptor may survive so a later authorized message can cold-resume the child, but the lost message is not replayed automatically. Recovering accepted unfinished or unlogged messages requires a durable inbox protocol and is not implied here. + +### Scope + +The MVP covers continuable in-process children and leaves one-shot delegation unchanged. Remote providers require a separate Activation handle with equivalent authenticated control and child-first quiescence contracts before they can support the same behavior. + +The MVP adds no subagent steering operation, report tool, child-to-parent content delivery, automatic parent wakeup, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public subagent cancellation operation, new live-Activation or descendant limit, or runtime cache. Existing delegation-depth policy remains unchanged. + +## Alternatives considered + +**Keep Task-backed Activations.** Tasks provide generic status, result collection, and cancellation, but using them for conversation delivery creates a second queue and duplicates turn ownership. The proposal gives up those generic Task controls so the Agent inbox remains the only execution order. + +**Create one Activation per `next-turn`.** This restores independent result and cancellation boundaries, but it requires a manager FIFO beside the Agent inbox and makes a retained Agent cross artificial Activation boundaries. One Activation per residency epoch is smaller and follows the `AgentHandle` lifetime directly. + +**Dispose the Agent while waiting.** Reconstructing a parent while its child still belongs to the previous process-local ownership graph would require a durable ownership and teardown protocol. Retaining the `AgentHandle` only for the unfinished graph preserves child-first teardown without keeping settled history resident. + +**Let the provider create, resume, or deliver through an Agent handle.** Initial providers own only `prepareContinuable()` and its detached creation-spec distinction: whether a child begins fresh or with a parent prefix. The manager must call `ctx.agents.create()` through its private activation-owner scope so that scope is a structural owner of every handle. A persisted in-process Session already contains the initial prefix and generic reconstruction descriptor, while delivery belongs to the Agent inbox. Giving providers any later handle, `SubagentRun`, or message ownership would preserve a seam with no MVP behavior to own and would complicate user cold resume with an unnecessary live-parent input. + +**Add report delivery to the MVP.** A repeatable model-facing tool is compatible with this lifecycle, but quiet versus waking delivery, recipient selection, acknowledgement, durability, and retry behavior are independent product choices. Deferring the tool keeps the first version focused on conversation admission and residency without constraining that later policy. + +**Treat `SessionHeader.parentSession` as live ownership.** Durable lineage does not prove that the historical parent currently owns the child. Membership in the live parent's `ownedChildren` records the process-local relationship without changing durable provenance. + +**Retain the exact parent Agent in a separate link.** The parent Activation already owns its `AgentHandle`, and `ownedChildren` prevents that Activation from disposing while the child remains live. Resolving the parent by Session id is therefore sufficient and avoids a redundant runtime reference. + +**Maintain a separate queue for parent messages.** A second FIFO creates ambiguous ordering against user messages already accepted by the Agent. A single Agent inbox gives both origins one observable order. + +**Expose subagent steering in the MVP.** User steering can be a strict live-only host action, but parent steering needs current-turn controller state to protect a user-controlled turn. Queueing every first-version continuation avoids that state and its admission race. A later UI can add a distinct user-only action without changing follow-up ordering. + +**Return a subagent-specific delivery route.** Labels such as `started`, `queued`, and `resumed` duplicate Activation and inbox state without giving the caller an independent result. Reusing `AgentMessageId` and the existing inbox events keeps delivery correlation on the Agent contract that owns it. + +**Use a child reference count.** A count cannot identify which child still owns teardown work and permits duplicate decrement errors. An identity set retains cancellation and disposal obligations explicitly. + +## Acceptance criteria + +- A continuable child has at most one live Activation and one Agent inbox; the continuation manager has no Activation FIFO or queued Activation state. +- `SubagentProvider.prepareContinuable?()` returns only a detached `ContinuableCreateSpec`; configured continuable mode requires that capability, while `backgroundMode` remains an independent policy choice. +- The manager calls `ctx.agents.create()` through its private activation-owner scope, installs the returned `AgentHandle` and parent ownership, calls `Agent.followup(initialPrompt)`, and returns `{ childId, messageId }` when inbox acceptance yields the `AgentMessageId`, without waiting for turn start or a Session-log write. +- Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership. +- Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through the initial subagent provider; `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. +- A continuable Activation directly owns `AgentHandle` and never creates, wraps, or retains `SubagentRun`; `SubagentProvider.start()` and `SubagentRun` remain one-shot-only, without `SubagentRun.steer?()`. +- A user can cold-resume a persisted child without loading its historical parent. +- `followup()` accepts only trusted parent or user authority; durable message provenance cannot authorize delivery. +- Parent and user continuation messages always use `Agent.followup()` and share its inbox FIFO, including when one origin queues behind the other or the child already has an open turn. +- `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `AgentMessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result. +- The MVP exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host and manager teardown retains child-first global cleanup. +- The MVP exposes no subagent steering operation or current-turn controller state. +- An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained. +- A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation. +- Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph. +- Final Activation settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` and rejection report `DURABILITY_FAILED`, still dispose the child handle, and still release parent ownership so durability failure cannot leak a `waiting` Activation. +- Host and manager teardown synchronously enter draining, reject new materialization and delivery, stop manager-owned outward notifications, dispose every snapshotted live Activation forest child-first, await every branch despite individual failures, and only then dispose top-level Agents and the manager scope; a private activation-owner scope preserves this order against Cordis effect unwinding, and one memoized disposal promise per Activation makes concurrent normal settlement idempotent. +- The MVP exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. +- Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. +- No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. +- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance failure, caller-signal ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. +- Unit coverage pins the residency-only routing table, single-inbox ordering, `AgentMessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools. +- A keyless assembled-app snapshot covers parent delegation, mixed parent/user follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal. + +## Risks + +Removing Tasks gives up generic background-work inspection, result collection, and exact Task cancellation. If those product features become requirements, they need a request ticket or inbox capability that does not reintroduce a second execution queue. + +Retaining an Activation while descendants run consumes Agent resources proportional to the unfinished ownership graph. The existing delegation-depth policy still bounds nesting, but the MVP adds no live-Activation or total-descendant limit; settled historical Sessions retain no `AgentHandle`. + +The process-local inbox and ownership graph do not coordinate two harness processes. Deployments allowing concurrent access to one persistence store still require a durable lease and mailbox protocol. + +Without report delivery, completing a child turn neither sends its content to nor wakes the historical parent. The output remains in the durable child Session until a caller inspects that transcript or submits another authorized turn. A later report tool may add quiet or waking delivery without changing the Activation lifecycle. + +Queueing every continuation message means a parent cannot correct an in-progress child turn immediately; the correction runs as the next turn. A later user-only UI steering action may reduce that latency without introducing parent-versus-user controller policy into the MVP. + +A failed final durability checkpoint allows the runtime ownership graph to drain but leaves the persisted child state missing or stale. The failure is observable as `DURABILITY_FAILED`; retry and repair require a separate recovery design. diff --git a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md new file mode 100644 index 0000000000..11f59d8f1a --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -0,0 +1,216 @@ +# Agent Note(agent 决策记录):可继续的 subagent + +Status: proposed + +[English](2026-07-28-continuable-subagent-conversations.md) | 中文 + +本提案将取代[可继续的后台 subagent](../../implemented/feature/2026-07-21-continuable-background-subagents.md)中由 Task 支撑的继续执行管理器。提案保留[将 subagent 控制合并到 subagent 服务](../../implemented/simplification/2026-07-26-merge-subagent-control-service.md)确立的单一 `ctx.subagents` 服务,以及[以意图命名的 subagent 继续执行操作](../../implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md)确立的 `followup` 操作。 + +## 问题 + +继续执行管理器目前让一个 Task、一次提供方执行和一个结果边界共享同一生命周期。Task 结算会 dispose(资源释放)child Agent,Task 完成会注入完成通知,后续输入则重建另一个 Agent。这使通用后台工作抽象与会话投递耦合,而可继续 subagent 已经具备会话和 Agent inbox。 + +如果继续执行管理器为 parent 请求排队,而 Agent 接收用户消息,系统就会出现两个 FIFO,且没有唯一的顺序权威。如果两种消息都交给 Task,系统又会重复 agent loop(智能体循环)已有的准入、取消和完全停稳机制。`Agent.whenIdle()` 无法恢复单项请求的 Task 结果,因为一个运行区间可能清空多个排队轮次;宽泛的 `Agent.cancel()` 也不能精确移除一项排队请求。 + +运行时生命周期也比单个轮次更长。subagent 可能已经结束自身轮次,但它创建的 child 仍在运行。此时 dispose parent 运行时,会移除仍负责后代拆卸的 Agent。反之,如果让所有历史 subagent 始终驻留,内存使用就会失去上界。 + +用户和 parent Agent 还需要在不改变当前轮次的前提下,向同一个在线 child 发送后续工作。将每条继续执行消息作为 follow-up 排队,可以让两类发送方遵循同一项排序规则。 + +## 提案 + +一个可继续 subagent 拥有一个持久化会话,并且至多拥有一个进程内激活: + +```text +persisted Session + -> optional live Activation + -> one retained AgentHandle + -> Agent inbox as the only turn FIFO + -> zero or more owned child Activations +``` + +激活是重建 child Agent 的一次驻留周期。它可以执行多个 FIFO 轮次,并在等待后代时保持驻留。它不是请求、结果、取消或 Task 边界。 + +继续执行管理器负责激活准入、权限检查、在线所有权图、冷恢复和 child-first dispose。Agent loop 负责全部轮次排序与执行。本提案不会为可继续 subagent 创建 Task、激活 FIFO 或 queued 激活状态。 + +### 物化与公开操作 + +具名 subagent 提供方只参与准备初始创建规格,此时 `spawn` 与 `fork` 有所区别。其可选的 `prepareContinuable(request): Promise` 方法就是可继续创建能力。返回的规格只包含与 Agent 实例分离且由提供方决定的创建输入,例如可选的 parent 历史种子;它不包含 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作。管理器会预留 child 身份,解析持久化描述符和通用 Agent 配置,通过私有 activation-owner 作用域调用 `ctx.agents.create()`,将返回的 `AgentHandle` 安装到激活中,建立适用的可继续 parent 所有权,然后调用 `Agent.followup(initialPrompt)`。inbox 接受消息后会产生一个 `AgentMessageId`;`ctx.subagents.startContinuable()` 在此边界返回 `{ childId, messageId }`,不等待轮次开始,也不等待消息写入会话日志。 + +inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的情况下被拒绝。Agent 创建流程负责 handle 移交前的回滚;移交后,管理器会先 dispose 已创建的 handle、移除激活并回滚 parent `ownedChildren` 中的任何成员关系,再拒绝操作。 + +`backgroundMode: 'one-shot' | 'continuable'` 仍是部署策略。配置为 continuable 时要求存在 `prepareContinuable`;该方法是否存在会取代 `SubagentProvider.resume?()` 成为能力检查,而具备该能力的提供方仍可运行 one-shot 工作。 + +冷恢复不会通过 subagent 提供方分发。继续执行管理器会归并通用的进程内描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,安装返回的 `AgentHandle`,并提交等待中的 `next-turn`。`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在,初始提供方名称也不是恢复能力;远程提供方需要单独设计。 + +`SubagentProvider.start()` 和 `SubagentRun` 只保留在不变的 one-shot 路径上。可继续激活直接持有自身的 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;因此,`SubagentRun.steer?()` 不存在。 + +`ctx.subagents.followup(authority, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。`authority` 可以是 `{ kind: 'parent', agent }` 或 `{ kind: 'user' }`;parent 变体仅能从确切的在线 Agent 工具上下文通过准入,只有可信宿主适配器才能提供用户权限。`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id` 和 `message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `AgentMessageId`,两者都不报告管理器如何物化激活。 + +对于 start 和 follow-up,调用方 signal 只在 inbox 接受消息前持有查找、物化和准入。操作返回 `AgentMessageId` 后,管理器会独立持有该激活;调用方之后的取消不会取消已接受的轮次,也不会 dispose child。 + +### 持久化会话与在线激活 + +会话持有稳定的 child 身份、transcript(文本记录)、直接 parent 谱系、委派深度和带版本的继续执行描述符。`SessionHeader.parentSession` 是持久化来源信息和鉴权输入;它不是在线路由能力,也不表示历史 parent 仍然驻留。 + +空闲的历史会话没有 `AgentHandle`。第一条通过鉴权的 `next-turn` 投递会根据持久化会话恢复激活,并将消息提交到其 inbox。经用户授权的冷恢复不会加载历史 parent Agent。parent 发起的恢复使用经过身份认证的确切在线 parent Agent 执行鉴权;当该 parent 有激活时,还使用它建立所有权,但绝不使用 parent 执行重建。 + +激活作为消费方会直接持有已发布的 `AgentHandle` 直至结算,而管理器的私有 activation-owner 作用域则是其 Cordis 结构化所有者。可继续 subagent 路径不创建任何中间的带结果执行包装层,包括 `SubagentRun`;一次性委派保持不变,且不属于该生命周期。远程提供方不在 MVP 范围内,引入时需要单独的激活所有权契约。激活 dispose 后,历史会话不消耗运行时内存。 + +### 激活生命周期 + +公开生命周期只有 3 个状态,没有 `queued` 状态: + +```text +running + | Agent quiescent with live children + v +waiting + | next-turn + +--------------------------> running + +running or waiting + | Agent quiescent and no live children + v +settled + | AgentHandle.dispose completes + v +no Activation +``` + +`running` 表示 Agent 正在执行准入或轮次,或者 inbox 中存在会唤醒 Agent 的工作。`waiting` 表示 Agent 已经完全停稳,但激活仍持有至少一个尚未完成 dispose 的 child 激活。`settled` 表示 Agent 已经完全停稳且所有持有的 child 都已 dispose;随后管理器会 dispose `AgentHandle` 并移除激活。 + +管理器根据 Agent 是否完全停稳以及所持 child 集合派生这些状态,而不是维护第二套执行状态机。在 `running` 时投递的 `next-turn` 会进入 Agent inbox。在 `waiting` 时投递的 `next-turn` 会唤醒同一个 Agent,并使激活回到 `running`。在 dispose 完成后投递消息则会冷恢复新激活。 + +管理器会针对每个持久化 child,将投递、child 释放和 dispose 线性化。如果投递与最终 dispose 发生竞争,只有一方能越过准入截止点:投递要么进入仍在线的 Agent inbox,要么等待 dispose 完成后冷恢复新激活。任何调用方都不能向已经开始 dispose 事务的 handle 发送消息。 + +### 一个 inbox 与 follow-up 投递 + +Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup()`,并成为一个 FIFO 轮次;继续执行管理器和宿主都不维护另一条消息队列。每个已接受且会唤醒 Agent 的条目都会让当前激活保持在线,直至 `Agent.whenIdle()` 观察到完整的唤醒工作后缀已经结束。 + +路由只取决于激活的驻留状态: + +| 激活状态 | 发送方 | `followup` | +|---|---|---| +| `running` | parent 或 user | 在同一激活中排队 | +| `waiting` | parent 或 user | 唤醒同一激活 | +| 无激活 | parent 或 user | 冷恢复新激活 | + +继续执行层不定义单独的投递路由结果。成功投递 `ctx.subagents.followup()` 或 `send_message` 时会返回已接受的 `AgentMessageId`,投递失败则会抛出异常。现有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 和 `agent/inbox/discard` 事件仍用于观测消息生命周期;适配器可以呈现通用的接受确认,但不暴露 `started`、`queued`、`resumed` 或其他 subagent 专属路由词汇。 + +### child 所有权 + +每次激活都持有自身的 `AgentHandle` 和一个 `ownedChildren: Set`。由于一个会话至多有一次在线激活,child 会话 id 足以标识在线 child,无需另一个运行时 incarnation 引用。`SessionHeader.parentSession` 记录持久化的直接 parent 身份,`ownedChildren` 中的成员关系则记录进程内所有权关系。 + +当经过身份认证的 parent 自身是由继续执行管理器管理的激活时,启动 child 或提交由 parent 发起的工作,会在 child 可以运行或消息可以进入其 inbox 前,将 child 会话 id 加入该 parent 的 `ownedChildren`。该集合非空时,这个 parent 不能结算或 dispose。顶层 Agent 或其他非继续执行 Agent 没有激活,也不会加入该等待图。 + +只有在 child Agent 完全停稳、该 child 持有的每个 child 都已 dispose、最终持久性检查点结算且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会调用 `ctx.sessions.flush(child.session)`:只有 `true` 确认持久性,`false` 或 rejection 则统一报告为 `DURABILITY_FAILED`。检查点失败会被报告,但不会阻止 handle dispose 或释放所有权,因为保留失败的 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id;由用户恢复且没有在线 owner 的 child 则没有需要释放的所有权记录。管理器拆卸使用相同的 child-first 顺序。 + +用户冷恢复会创建一次激活,但不会将其加入历史 parent 的 `ownedChildren`。如果直接 parent 随后向这个在线激活提交工作,且该 parent 自身由继续执行管理器管理,准入过程会在消息入队前建立所有权;非继续执行 parent 仍位于等待图之外。 + +MVP 会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 + +顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain,并涵盖由用户恢复且没有在线 owner 的激活。 + +activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。管理器在关闭准入后对在线根节点创建快照,在取消前停止自身的对外生命周期通知,并保留内部所有权簿记,直至每个 handle 都结算。每次激活有一个记忆化的 dispose promise,使宿主关闭、管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余 handle,聚合 drain 则在所有分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。 + +### 延后的报告投递 + +MVP 不暴露 `report` 工具,也不提供从 child 到 parent 的内容投递或自动唤醒 parent。持久化 child 会话仍是 child 详细输出的来源。 + +后续提案可以增加一个普通的面向模型 `report(output)` 工具;模型在一个轮次中可以调用它零次或多次。其投递策略可以区分静默注入 parent 与唤醒 parent;接收方选择、确认、持久性和重试语义均与该工具一并延后决定。增加报告投递无需引入另一个激活状态或执行队列。 + +### 延后的 steering(中途引导) + +MVP 不暴露 subagent steering 操作。parent 和用户的继续执行消息始终开启后续 FIFO 轮次,因此继续执行层不存储当前轮次控制方,也不新增能够感知控制方的 Agent 准入 seam。 + +后续宿主 UI 可以分别暴露 **Steer** 和 **Follow up** 操作。用户 steering 必须严格且仅限在线使用:只有当激活接受下一步骤时,它才能调用现有的 Agent steering 路径;其他情况必须拒绝,而且绝不能转为排队或冷恢复。是否通过面向模型的工具暴露 parent steering 仍需单独设计,因为不同的工具名称可以表达意图,却不能确定 parent 是否可以修改由用户控制的轮次。 + +### 权限与来源 + +权限来自可信宿主交互或确切的在线 Agent 工具上下文。`MessageSource` 和 `senderSessionId` 是准入后的持久化来源信息,不是由调用方控制的权限。 + +MVP 授权宿主用户和持久化 child 的直接 parent。系统会根据经过身份认证的 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、团队和工作流仍被拒绝,直至系统具备显式权限协议。 + +用户权限可以在 parent 不在线时冷恢复 child。由 parent 发起的投递要求 parent 在准入时在线,并通过所有权关系使其继续在线。 + +### 持久性、dispose 与恢复 + +没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 和用户都不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本 MVP 不通过 subagent 服务暴露它。 + +宿主和管理器拆卸仍是覆盖整个生命周期的停止路径。它会关闭准入,按 child-first 顺序 dispose 每个在线激活森林,并保留持久化会话。 + +每个轮次都会请求执行会话持久性检查点,激活最终结算时,管理器必须检查 `ctx.sessions.flush()`,而不能忽略其布尔结果。`true` 确认至少有一个持久性 listener 参与,且所有 listener 都成功结算。`false` 或 rejection 会报告 `DURABILITY_FAILED`;普通后台结算会记录该生命周期失败,显式的宿主或管理器 drain 则会在所有分支结算后,将其纳入聚合 rejection。无论结果如何,管理器仍会 dispose handle 并释放所有权,后续恢复时持久化 child 状态可能缺失或陈旧。 + +只有实际写入 child 会话日志的消息,才能根据其准入来源重建;仅被 inbox 接受并不提供重启保证。 + +会话和描述符的持久化状态可在重启后保留。激活状态、Agent inbox 内容和所有权图都是进程内状态。进程崩溃可能丢失已被接受但仍留在 inbox、尚未写入会话日志的初始提示词或 follow-up。会话和描述符可能保留,因此后续获得授权的消息仍可冷恢复 child,但丢失的消息不会自动回放。恢复已接受但未完成或未写入日志的消息需要持久化 inbox 协议,本提案不隐含该能力。 + +### 范围 + +MVP 覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳契约,才能支持同样的行为。 + +MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的内容投递、自动唤醒 parent、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。 + +## 曾考虑的替代方案 + +**保留由 Task 支撑的激活。** Task 可以提供通用状态、结果收集和取消,但使用 Task 投递会话会产生第二条队列,并重复轮次所有权。本提案放弃这些通用 Task 控制,让 Agent inbox 成为唯一执行顺序。 + +**每个 `next-turn` 创建一次激活。** 这会恢复独立的结果与取消边界,但需要在 Agent inbox 旁维护管理器 FIFO,还会使所保留的 Agent 跨越人为划分的激活边界。每个驻留周期对应一次激活更小,也直接跟随 `AgentHandle` 生命周期。 + +**等待期间 dispose Agent。** child 仍属于上一个进程内所有权图时重建 parent,需要持久化所有权与拆卸协议。只为尚未完成的所有权图保留 `AgentHandle`,可以在不让已结算历史驻留的前提下,保留 child-first 拆卸。 + +**让提供方通过 Agent handle 创建、恢复 child 或投递消息。** 初始提供方只持有 `prepareContinuable()` 及其分离式创建规格这一项差异:child 是全新启动,还是带有 parent 前缀。管理器必须通过私有 activation-owner 作用域自行调用 `ctx.agents.create()`,使该作用域成为每个 handle 的结构化所有者。持久化的进程内会话已经包含初始前缀及通用重建描述符,消息投递则属于 Agent inbox。让提供方持有任何后续 handle、`SubagentRun` 或消息所有权,会保留一条没有 MVP 行为可承载的 seam,还会因不必要的在线 parent 输入使用户冷恢复更加复杂。 + +**在 MVP 中增加报告投递。** 可重复调用的面向模型工具与该生命周期兼容,但静默投递还是唤醒投递、接收方选择、确认、持久性和重试行为都是独立的产品决策。延后该工具,可以让首个版本专注于会话准入与驻留,又不限制后续策略。 + +**将 `SessionHeader.parentSession` 视为在线所有权。** 持久化谱系不能证明历史 parent 当前持有 child。在线 parent 的 `ownedChildren` 成员关系会记录进程内关系,而不改变持久化来源。 + +**在单独的 link 中保留确切的 parent Agent。** parent 激活已经持有自身 `AgentHandle`,而且 `ownedChildren` 会在 child 仍然在线时阻止该激活 dispose。因此,通过会话 id 解析 parent 已经足够,也可以避免冗余的运行时引用。 + +**为 parent 消息维护单独队列。** 第二个 FIFO 会让它和 Agent 已接受的用户消息之间顺序不明确。单个 Agent inbox 为两种来源提供唯一且可观察的顺序。 + +**在 MVP 中暴露 subagent steering。** 用户 steering 可以是严格且仅限在线使用的宿主操作,但 parent steering 需要当前轮次控制方状态,以保护由用户控制的轮次。首个版本将每条继续执行消息都排队,可以避免引入该状态及其准入竞争。后续 UI 可以新增一项仅限用户的独立操作,而不改变 follow-up 排序。 + +**返回 subagent 专属的投递路由。** `started`、`queued` 和 `resumed` 等标签重复了激活与 inbox 状态,却没有给调用方提供独立结果。复用 `AgentMessageId` 和现有 inbox 事件,可以让投递关联继续由其所属的 Agent 契约承载。 + +**使用 child 引用计数。** 计数无法识别哪个 child 仍持有拆卸工作,也允许重复递减错误。身份集合会显式保留取消和 dispose 义务。 + +## 验收标准 + +- 可继续 child 至多拥有一个在线激活和一个 Agent inbox;继续执行管理器没有激活 FIFO 或 queued 激活状态。 +- `SubagentProvider.prepareContinuable?()` 只返回分离式 `ContinuableCreateSpec`;配置为 continuable 时要求具备该能力,而 `backgroundMode` 仍是独立的策略选择。 +- 管理器通过私有 activation-owner 作用域调用 `ctx.agents.create()`,安装返回的 `AgentHandle` 并建立 parent 所有权,调用 `Agent.followup(initialPrompt)`,然后在 inbox 接受消息并产生 `AgentMessageId` 时返回 `{ childId, messageId }`,而不等待轮次开始或消息写入会话日志。 +- 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系。 +- 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过初始 subagent 提供方分发;`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。 +- 可继续激活直接持有 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;`SubagentProvider.start()` 和 `SubagentRun` 只用于 one-shot,且没有 `SubagentRun.steer?()`。 +- 用户可以在不加载历史 parent 的前提下冷恢复持久化 child。 +- `followup()` 只接受可信 parent 或用户权限;持久化消息来源信息不能授权投递。 +- Parent 和用户的继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括一种来源排在另一种来源之后,以及 child 已有开放轮次的情况。 +- `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `AgentMessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 +- MVP 不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 +- MVP 不暴露 subagent steering 操作或当前轮次控制方状态。 +- 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。 +- 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。 +- 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。 +- 激活最终结算时,只有 `ctx.sessions.flush(child.session) === true` 才确认持久性;`false` 和 rejection 会报告 `DURABILITY_FAILED`,但仍会 dispose child handle 并释放 parent 所有权,使持久性失败不会泄漏 `waiting` 激活。 +- 宿主和管理器拆卸会同步进入 draining,拒绝新的物化和投递,停止由管理器负责的对外通知,按 child-first 顺序 dispose 处于快照中的整个在线激活森林,即使个别分支失败也会等待所有分支,之后才 dispose 顶层 Agent 和管理器作用域;私有 activation-owner 作用域会确保 Cordis effect 的逆序撤销不破坏该顺序,每次激活使用一个记忆化的 dispose promise,使并发的正常结算保持幂等。 +- MVP 不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 +- 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 +- 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 +- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前失败路径的完整回滚、接受前后两个阶段的调用方 signal 所有权,以及已接受但未写入日志的消息不会自动回放。 +- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `AgentMessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 +- 一项无密钥整套应用快照覆盖 parent 委派、parent 与用户混合的 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 + +## 风险 + +移除 Task 会放弃通用后台工作检查、结果收集和精确 Task 取消。如果这些产品功能成为需求,就需要不会重新引入第二条执行队列的请求 ticket 或 inbox 能力。 + +在后代运行期间保留激活,会按尚未完成所有权图的规模消耗 Agent 资源。现有委派深度策略仍会限制嵌套层级,但 MVP 不新增在线激活数量或后代总数限制;已结算的历史会话不保留 `AgentHandle`。 + +进程内 inbox 和所有权图无法协调两个 harness 进程。允许多个进程并发访问同一持久化存储的部署,仍需要持久化 lease 和邮箱协议。 + +没有报告投递时,完成 child 轮次既不会把内容发送给历史 parent,也不会唤醒它。输出会保留在持久化 child 会话中,直至调用方检查该 transcript 或提交另一个经过授权的轮次。后续报告工具可以增加静默投递或唤醒投递,而无需改变激活生命周期。 + +将每条继续执行消息排队,意味着 parent 无法立即纠正正在进行的 child 轮次;纠正操作会在下一个轮次执行。后续仅限用户的 UI steering 操作可以缩短该延迟,而无需在 MVP 中引入 parent 与用户之间的控制方策略。 + +最终持久性检查点失败时,运行时所有权图仍可完成 drain,但持久化 child 状态会缺失或陈旧。该失败会以 `DURABILITY_FAILED` 的形式被观测到;重试与修复需要单独的恢复设计。 diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 8461795120..f4708dc14f 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -12,12 +12,13 @@ import z from 'schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { + ContinuableCreateRequest, + ContinuableCreateSpec, SubagentCapabilities, SubagentProvider, - SubagentProviderResumeRequest, - SubagentProviderStartRequest, + SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' -import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-fork' // `tools` is deliberately NOT injected — same rationale as subagent-spawn: the @@ -64,7 +65,7 @@ class ForkProvider implements SubagentProvider { constructor(readonly name: string) {} - start(request: SubagentProviderStartRequest) { + start(request: SubagentStartRequest) { const seed = completedTurnPrefix(request.parent) return startInProcessRun(request, { // Only pass a seed when there's a completed turn to inherit; an empty seed @@ -73,11 +74,12 @@ class ForkProvider implements SubagentProvider { }) } - resume(request: SubagentProviderResumeRequest) { - // Cold resume loads the child's OWN persisted transcript, which already - // contains the completed-turn prefix captured at initial creation; it - // never forks the parent's newer history again. - return resumeInProcessRun(request) + prepareContinuable(request: ContinuableCreateRequest): Promise { + // The fork prefix is captured ONCE, at creation: it becomes part of the + // child's own durable transcript, so a later cold resume replays that + // prefix instead of re-forking the parent's newer history. + const seed = completedTurnPrefix(request.parent) + return Promise.resolve(seed.length > 0 ? { seed } : {}) } } diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 38fd418f39..ddbcf8753e 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -1,24 +1,32 @@ /** - * Shared driver for in-process subagent providers. The agent factory's + * Shared driver for in-process ONE-SHOT subagent providers. The agent factory's * creation transaction owns unpublished setup and rollback; after publication * the returned AgentHandle is the one quiescent lifecycle owner held by the * provider's caller. * + * Continuable children never come through here: the continuation manager + * composes and drives them directly, so this driver owns exactly one turn with + * one result. + * * @module @deepseek-ai/dsh-subagent-inprocess */ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' -import { createUserMessage, errorChain, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' -import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent' +import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { + applyChildComposition, + assertSubagentMaxDepth, + childSessionMeta, + resolveChildAgentOptions, + resolveChildDepth, +} from '@deepseek-ai/dsh-subagent' import type { - SubagentDescriptorData, - SubagentProviderResumeRequest, - SubagentProviderStartRequest, SubagentResult, SubagentRun, + SubagentStartRequest, SubagentStopReason, } from '@deepseek-ai/dsh-subagent' // Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve @@ -36,14 +44,6 @@ export { STRUCTURED_OUTPUT_INSTRUCTION, } from './structured.ts' -/** Thrown when starting a child would exceed the requested depth cap. */ -class SubagentDepthError extends Error { - constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) { - super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`) - this.name = 'SubagentDepthError' - } -} - /** Map a session turn outcome to the subagent seam's terminal vocabulary. */ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { switch (reason?.kind) { @@ -67,76 +67,31 @@ export interface InProcessRunOptions { readonly seed?: SessionEvent[] } -/** Whether one activation must prove its final state durable before success. */ -type Durability = 'best-effort' | 'required' - -/** Activation-specific inputs to the shared in-process driver. */ -interface DriveTurnOptions { - readonly durability: Durability - /** Attribution for a resumed activation's follow-up prompt. */ - readonly source?: MessageSource - readonly structured?: StructuredAttachment -} - /** Error used when cancellation wins before the child publication boundary. */ function prePublicationAbort(): Error { return new Error('subagent request was aborted before child publication') } /** - * Register the one-shot child-scoped contribution that appends the durable - * `subagent/descriptor` event. The prepended `agent/prompt-submit` wrapper - * appends before downstream admission can block or throw. Allowed admission - * opens the initial turn afterward; the final required checkpoint also - * persists the descriptor when no turn opens. - */ -function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { - childCtx.once('agent/prompt-submit', (agent, _message, _signal, next) => { - agent.session.append('subagent/descriptor', descriptor) - return next() - }, { prepend: true }) -} - -/** - * Establish and drive one in-process child. Fulfillment means the agent is - * already published in the registry; rejection means the agent factory's + * Establish and drive one in-process one-shot child. Fulfillment means the agent + * is already published in the registry; rejection means the agent factory's * creation transaction and any partially-created child have reached quiescence. - * A `request.continuation` publishes exactly its stable child id and appends - * its descriptor before the child's initial prompt admission. * @param request - the trusted typed start request, including its required signal. * @param options - the optional fork seed. * @returns a ready holder-owned run. */ export async function startInProcessRun( - request: SubagentProviderStartRequest, + request: SubagentStartRequest, options: InProcessRunOptions, ): Promise { assertSubagentMaxDepth(request.maxDepth) if (request.signal.aborted) throw prePublicationAbort() const parent = request.parent - const childDepth = delegationDepthOf(parent) + 1 - if (!Number.isSafeInteger(childDepth)) { - throw new RangeError('subagent child depth exceeds the safe-integer range') - } - if (request.maxDepth !== undefined && childDepth > request.maxDepth) { - throw new SubagentDepthError(childDepth, request.maxDepth) - } + const childDepth = resolveChildDepth(parent, request.maxDepth) - // A continuable delegation names the durable conversation up front; the - // provider publishes exactly that id instead of allocating one internally. - const childId = request.continuation?.sessionId ?? SessionId(randomUUID()) - const seedLength = options.seed?.length ?? 0 - const parentHeader = parent.session.header - const parentProvider = parent.options.provider - const parentModel = parent.options.model - const parentMaxTokens = parent.options.maxTokens - const agentOptions: AgentOptions = { - ...parentProvider !== undefined ? { provider: parentProvider } : {}, - ...parentModel !== undefined ? { model: parentModel } : {}, - ...parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {}, - ...request.agentOptions, - subagentDepth: childDepth, - } + const childId = SessionId(randomUUID()) + const seed = options.seed + const activationBoundary = seed?.length ?? 0 // Capture before the first await: a later parent switch belongs to the // parent's future. @@ -145,6 +100,8 @@ export async function startInProcessRun( let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { + // Inherited overrides land on the child's own log, so its effective policy + // is reconstructable from that log alone. const childSession = (childCtx.agent as Agent).session if (inheritedMode !== undefined) { childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' }) @@ -152,29 +109,20 @@ export async function startInProcessRun( if (inheritedPolicy !== undefined) { childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' }) } - if (request.persona !== undefined) { - childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona }) - } - if (request.toolFilter !== undefined) childCtx.tools.restrict(request.toolFilter) + applyChildComposition(childCtx, { + persona: request.persona, + toolFilter: request.toolFilter, + }) if (request.outputSchema !== undefined) { structured = attachStructuredRuntime(childCtx, request.outputSchema) } - if (request.continuation !== undefined) { - attachDescriptorAppend(childCtx, request.continuation.descriptor) - } } const handle = await parent.ctx.agents.create({ sessionId: childId, - meta: { - ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, - parentSession: parentHeader.id, - // Durable: the recursion budget must survive persistence and resume. - delegationDepth: childDepth, - ...seedLength > 0 ? { seedLength } : {}, - }, - ...options.seed === undefined ? {} : { seed: options.seed }, - agentOptions, + meta: childSessionMeta(parent, childDepth, activationBoundary), + ...seed !== undefined ? { seed } : {}, + agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), signal: request.signal, setup, }) @@ -183,62 +131,15 @@ export async function startInProcessRun( request.signal, request.prompt, childId, - seedLength, - { - durability: request.continuation === undefined ? 'best-effort' : 'required', - ...structured === undefined ? {} : { structured }, - }, + activationBoundary, + structured, ) } /** - * Reconstruct a persisted continuable child under the live parent's scope and - * drive one follow-up turn. The resumed session's own transcript is the seed - * (loaded through the parent's persistence-backed registry `resume`), so a - * fork child never re-forks current parent history; the persisted header - * remains authoritative for lineage and the delegation-depth floor. - * @param request - the fully resolved resume request from the continuation manager. - * @returns a fresh ready holder-owned run for this activation. - */ -export async function resumeInProcessRun(request: SubagentProviderResumeRequest): Promise { - if (request.signal.aborted) throw prePublicationAbort() - const descriptor = request.descriptor - const agentOptions: AgentOptions = { - ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, - ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, - } - const setup = (childCtx: Context): void => { - if (descriptor.persona !== undefined) { - childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: descriptor.persona }) - } - if (descriptor.toolFilter !== undefined) childCtx.tools.restrict(descriptor.toolFilter) - } - - const handle = await request.parent.ctx.agents.resume({ - resumeSessionId: request.sessionId, - agentOptions, - signal: request.signal, - setup, - }) - // The result boundary is this activation's own work: everything already in - // the resumed transcript belongs to earlier turns. - const resumePoint = handle.agent.session.events.length - return driveTurn( - handle, - request.signal, - request.prompt, - request.sessionId, - resumePoint, - { durability: 'required', source: request.source }, - ) -} - -/** - * Drive one activation turn on a published child and wrap it as a run. The - * caller has already created or resumed the agent; this owns the - * signal-handoff race, the live abort listener, result collection past - * `boundary`, the continuable-run durability confirmation, confirmed - * steering, and disposal. + * Drive one turn on a published child and wrap it as a run. The caller has + * already created the agent; this owns the signal-handoff race, the live abort + * listener, result collection past `boundary`, and disposal. */ function driveTurn( handle: AgentHandle, @@ -246,10 +147,9 @@ function driveTurn( prompt: ContentBlock[], childId: SessionId, boundary: number, - options: DriveTurnOptions, + structured: StructuredAttachment | undefined, ): SubagentRun | Promise { const child = handle.agent - const { durability, source, structured } = options // Agent creation detaches its creation-only abort listener before returning. // Close the narrow handoff race before installing the live-run listener. if (signal.aborted) { @@ -265,30 +165,13 @@ function driveTurn( const result: Promise = (async () => { try { - child.followup(createUserMessage({ content: prompt, source: source ?? { kind: 'user' } })) + child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } })) await child.whenIdle() - if (durability === 'required') { - try { - const participated = await child.ctx.sessions.flush(child.session) - if (!participated) { - throw new Error(`session "${child.id}" required durability checkpoint has no registered listener`) - } - } catch (error: unknown) { - if (!signal.aborted) { - throw new SubagentError( - `subagent "${childId}" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`, - 'DURABILITY_FAILED', - { cause: error }, - ) - } - } - } return readResult( child, boundary, flags.cancelled, structured ? { captured: structured.captured() } : undefined, - durability === 'required' && signal.aborted, ) } finally { signal.removeEventListener('abort', onAbort) @@ -304,23 +187,6 @@ function driveTurn( flags.cancelled = true return handle.dispose() }, - async steer(content: ContentBlock[], steeringSource: MessageSource): Promise { - // The status check and submission share one synchronous frame. An idle - // Agent.steer() would queue an untracked turn after this run's result. - if (child.status !== 'running') { - throw new Error(`subagent child "${childId}" is not running; the message was not delivered`) - } - // Avoid waiting for the structured terminal checkpoint when its outcome - // is already authoritative and synchronously visible. - if (structured?.captured() !== undefined) { - throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`) - } - const receipt = child.steer(createUserMessage({ content, source: steeringSource })) - const outcome = await receipt.outcome - if (outcome.status === 'rejected') { - throw new Error(`subagent child "${childId}" stopped before steering admission; the message was not delivered`) - } - }, } } @@ -330,7 +196,6 @@ function readResult( boundary: number, cancelled: boolean, structured?: { captured?: { value: unknown } | undefined }, - cancellationOwnsCompleted = false, ): SubagentResult { const own = child.session.events.slice(boundary) const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message') @@ -338,13 +203,8 @@ function readResult( const output: ContentBlock[] = lastMessage?.data.message.content ?? [] const recorded = toStopReason(lastEnd?.data.reason) // Disposal can tear the owner down before the loop records its ordinary - // `aborted` end, yielding `disposed` instead. Activation cancellation during - // its final durability checkpoint also owns a recorded completed turn because - // the provider has not published that result yet. - const stopReason: SubagentStopReason = cancelled - && (recorded !== 'completed' || cancellationOwnsCompleted) - ? 'aborted' - : recorded + // `aborted` end, yielding `disposed` instead. + const stopReason: SubagentStopReason = cancelled && recorded !== 'completed' ? 'aborted' : recorded if (structured !== undefined) { if (structured.captured !== undefined) { return { output, structured: structured.captured.value, stopReason } diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 0080c31521..7dceeac2ae 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -9,12 +9,12 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { + ContinuableCreateSpec, SubagentCapabilities, SubagentProvider, - SubagentProviderResumeRequest, - SubagentProviderStartRequest, + SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' -import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' // `tools` is deliberately not injected: the child factory already provides it during setup, @@ -45,17 +45,17 @@ class SpawnProvider implements SubagentProvider { constructor(readonly name: string) {} - start(request: SubagentProviderStartRequest) { + start(request: SubagentStartRequest) { // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ // depth, drives the one-shot (including the structured capture when the // request carries an outputSchema), and maps the result. return startInProcessRun(request, {}) } - resume(request: SubagentProviderResumeRequest) { - // Cold resume reconstructs the persisted child from its own transcript - // under the live parent scope; the shared driver drives the follow-up turn. - return resumeInProcessRun(request) + prepareContinuable(): Promise { + // A spawned child starts fresh, so it contributes no seed; the continuation + // manager owns every later operation on it. + return Promise.resolve({}) } } diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts new file mode 100644 index 0000000000..f93e5d5dff --- /dev/null +++ b/packages/subagent/subagent/src/child-agent.ts @@ -0,0 +1,128 @@ +/** + * Shared in-process child composition: the delegation-depth budget, the + * durable session metadata, the resolved child `AgentOptions`, and the scoped + * setup a child agent needs. Both the one-shot provider driver and the + * continuation manager compose children this way, so depth accounting and + * lineage stamping have one home. + * + * @module @deepseek-ai/dsh-subagent/child-agent + */ + +import type { Context } from 'cordis' +import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { ToolRestriction } from '@deepseek-ai/dsh-tools' +import { delegationDepthOf } from './depth.ts' + +/** Thrown when starting a child would exceed the requested depth cap. */ +export class SubagentDepthError extends Error { + constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) { + super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`) + this.name = 'SubagentDepthError' + } +} + +/** + * Resolve the child's delegation depth from its parent and enforce an optional + * cap. The persisted parent header is the monotone floor, so a resumed parent + * cannot delegate as if it were top-level. + * @param parent - the delegating parent agent. + * @param maxDepth - optional absolute cap the resolved depth must not exceed. + * @returns the child's non-negative safe-integer depth. + * @throws {SubagentDepthError} when the resolved depth exceeds `maxDepth`. + * @throws {RangeError} when the resolved depth leaves the safe-integer range. + */ +export function resolveChildDepth(parent: Agent, maxDepth: number | undefined): number { + const childDepth = delegationDepthOf(parent) + 1 + if (!Number.isSafeInteger(childDepth)) { + throw new RangeError('subagent child depth exceeds the safe-integer range') + } + if (maxDepth !== undefined && childDepth > maxDepth) { + throw new SubagentDepthError(childDepth, maxDepth) + } + return childDepth +} + +/** + * Resolve the child's `AgentOptions`: the parent's provider/model/maxTokens + * route unless the request overrides it, stamped with the child's own + * delegation depth. + * @param parent - the delegating parent whose route the child inherits. + * @param requested - per-child overrides, if any. + * @param childDepth - the resolved delegation depth to stamp. + * @returns the resolved options for `ctx.agents.create()`. + */ +export function resolveChildAgentOptions( + parent: Agent, + requested: AgentOptions | undefined, + childDepth: number, +): AgentOptions { + const parentProvider = parent.options.provider + const parentModel = parent.options.model + const parentMaxTokens = parent.options.maxTokens + return { + ...parentProvider !== undefined ? { provider: parentProvider } : {}, + ...parentModel !== undefined ? { model: parentModel } : {}, + ...parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {}, + ...requested, + subagentDepth: childDepth, + } +} + +/** + * Build the child session's durable creation metadata: the parent's workspace, + * its direct lineage, the recursion budget that must survive persistence, and + * the seed boundary that separates inherited parent history from child work. + * @param parent - the delegating parent agent. + * @param childDepth - the resolved delegation depth to persist. + * @param lineageSeedLength - how many leading events came from the parent's log. + * @returns the `meta` for `ctx.agents.create()`. + */ +export function childSessionMeta( + parent: Agent, + childDepth: number, + lineageSeedLength: number, +): NonNullable { + const parentHeader = parent.session.header + return { + ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, + parentSession: parentHeader.id, + // Durable: the recursion budget must survive persistence and resume. + delegationDepth: childDepth, + ...lineageSeedLength > 0 ? { seedLength: lineageSeedLength } : {}, + } +} + +/** The scoped composition a child agent's creation window applies. */ +export interface ChildComposition { + /** Per-child persona shadowing the deployment persona. */ + readonly persona?: string | undefined + /** Per-child tool scoping. */ + readonly toolFilter?: ToolRestriction | undefined +} + +/** + * Apply one child's scoped composition inside its creation window: a shadowing + * persona section and a tool restriction, both owned by the child's scope and + * therefore invisible to its parent and siblings. + * @param childCtx - the child agent's scoped creation context. + * @param composition - the persona and tool filter to install. + */ +export function applyChildComposition(childCtx: Context, composition: ChildComposition): void { + if (composition.persona !== undefined) { + childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona }) + } + if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter) +} + +/** Identity and lineage inputs shared by every in-process child creation. */ +export interface ChildCreateInputs { + /** The child's reserved session id. */ + readonly sessionId: SessionId + /** The delegating parent agent. */ + readonly parent: Agent + /** The resolved delegation depth. */ + readonly childDepth: number + /** How many leading seed events came from the parent's log. */ + readonly lineageSeedLength: number +} diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 1f4aa54a0c..80021ce205 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -1,34 +1,43 @@ /** * Internal continuable-subagent manager: stable child ids, descriptor - * persistence and lookup by known child id, Task-backed activation, and - * steer-or-resume message routing behind `ctx.subagents`. + * persistence, activation admission, the live ownership graph, cold resume, + * and child-first disposal behind `ctx.subagents`. * - * Every continuable activation — initial or resumed, parent- or human-started - * — has exactly one Task and one result. Task settlement awaits the child - * result, disposes the run, and only then records the outcome, so a terminal - * Task leaves the durable child session but no live child Agent. Cancellation - * targets the whole activation: parent and human messages that joined one - * turn share its result and its `killed` outcome. + * A continuable child has one durable Session and at most one process-local + * {@link Activation} — one residency epoch for a reconstructed child Agent. An + * Activation is not a request, result, cancellation, or Task boundary: it may + * execute many FIFO turns and stays resident while descendants it created are + * still running. The Agent inbox is the only turn queue, so this manager owns + * residency while the Agent loop owns all turn ordering and execution. No + * continuable path creates a Task or an intermediate result-bearing wrapper. * * @module @deepseek-ai/dsh-subagent */ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' import type { - SubagentProviderResumeRequest, - SubagentProviderStartRequest, - SubagentResult, - SubagentRun, - SubagentStartRequest, -} from './types.ts' -import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks' + Agent, + AgentHandle, + AgentOptions, + CreateAgentOptions, +} from '@deepseek-ai/dsh-agent' +import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { ToolRestriction } from '@deepseek-ai/dsh-tools' +import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' +import type { SubagentDescriptorData } from './descriptor.ts' +import { + applyChildComposition, + childSessionMeta, + resolveChildAgentOptions, + resolveChildDepth, +} from './child-agent.ts' +import { seedDescriptorTurn } from './descriptor-seed.ts' +import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts' import { SubagentError } from './error.ts' /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -44,197 +53,251 @@ declare module '@deepseek-ai/dsh-llm' { } } +/** + * Who authorizes one continuable-subagent operation. Authority comes from a + * trusted host interaction or an exact live Agent tool context; durable + * {@link MessageSource} provenance never authorizes delivery. + */ +export type SubagentAuthority = + /** The exact live parent Agent whose tool context is making the call. */ + | { readonly kind: 'parent'; readonly agent: Agent } + /** A trusted host adapter acting for the human user. */ + | { readonly kind: 'user' } + /** What a caller asks for when starting a continuable background child. */ export interface ContinuableStartSpec { - /** The `ctx.subagents` provider to establish the child on. */ + /** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */ readonly provider: string - /** One-line model-facing Task label (the delegation description). */ - readonly label: string /** - * The delegation request. The service resolves the stable child id and the - * durable descriptor, then supplies the Task-owned cancellation signal and - * `continuation` itself. + * The delegation request. The manager reserves the stable child id, resolves + * the durable descriptor, and composes the child itself. */ - readonly request: Omit + readonly request: Omit + /** Caller cancellation, owning the operation only until inbox acceptance. */ + readonly signal: AbortSignal } -/** Identities returned by a continuable start. */ +/** Identities returned once a continuable child accepted its initial prompt. */ export interface ContinuableStart { /** The durable child session id, stable across activations. */ readonly childId: SessionId - /** The initial activation's Task id. */ - readonly taskId: TaskId + /** The accepted initial prompt's inbox message id. */ + readonly messageId: MessageId } -/** - * Options for following up with one continuable child. - */ +/** Options for following up with one continuable child. */ export interface SubagentFollowupOptions { - /** Durable attribution retained on either live or resumed delivery. */ + /** Durable attribution retained on the delivered message; it grants no authority. */ readonly source: MessageSource - /** Caller cancellation for a live-delivery admission wait. */ + /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal } /** - * How a continuable follow-up was routed: - * `steered` joined the running activation's existing Task without creating a - * Task of its own; `started` created a fresh Task that cold-resumes the - * durable child with the content. Failure is an exception, never a result — - * undelivered content throws. + * The public residency state of one continuable child, derived from Agent + * quiescence and the owned-child set rather than a second state machine: + * `running` — the Agent has an active admission or turn, or waking inbox work; + * `waiting` — the Agent is quiescent but still owns undisposed children; + * `settled` — quiescent with every owned child disposed, so the manager + * disposes the `AgentHandle` and removes the Activation. */ -export type SubagentFollowupResult = - | { readonly route: 'steered'; readonly taskId: TaskId } - | { readonly route: 'started'; readonly taskId: TaskId } - -type StartProvider = (name: string, request: SubagentProviderStartRequest) => Promise -type ResumeProvider = (request: SubagentProviderResumeRequest) => Promise +export type ActivationState = 'running' | 'waiting' | 'settled' /** - * One child's current process-local activation: its Task and, after provider - * publication, its run. Installed before any provider or persistence await - * and removed only after run disposal and Task terminal publication. This - * exists solely so parent and human senders can find the same activation — it - * is not a durable catalog, admission reservation, or run-state machine. + * Lifecycle observer for one Activation's residency epoch, so continuable + * children emit the same start/end pair as one-shot runs. */ -interface ActiveActivation { - /** Assigned in the same synchronous frame as the install, when the Task registers. */ - taskId: TaskId | undefined - /** Filled when the provider publishes; `undefined` while starting or resuming. */ - run: SubagentRun | undefined - /** The activation-owned cancellation authority, created before any await. */ - readonly controller: AbortController - /** The producer's settlement (run disposed, outcome produced); assigned when the Task registers. */ - done: Promise | undefined - /** Resolved by the completion listener when the Task's terminal snapshot is recorded. */ - readonly terminal: PromiseWithResolvers +export interface ActivationObserver { + /** Publish the start edge once the epoch is resident. */ + start(): void + /** + * Publish the terminal edge exactly once. An epoch that never became resident + * emits nothing, because it has no start edge to pair. + * @param child - the child agent whose final output the edge reports, if any. + * @param failure - the teardown or durability failure, or `undefined` on success. + */ + settle(child: Agent | undefined, failure: unknown): void +} + +/** Hooks the manager needs from the owning service. */ +export interface ContinuationHost { + /** + * Resolve one provider's continuable-creation contribution, or reject when + * the provider is unknown or lacks the capability. + * @param name - the configured provider name. + * @param request - the reserved identity, delegating parent, and cancellation. + * @returns the provider's detached creation spec. + */ + prepareContinuable(name: string, request: ContinuableCreateRequest): Promise + /** + * Build the lifecycle observer for one Activation's residency epoch. + * @param provider - the provider name recorded in the durable descriptor. + * @param childId - the durable child session id. + * @param parent - the delegating parent for scoped dispatch, if any. + * @returns the observer whose edges this epoch publishes. + */ + observeActivation(provider: string, childId: SessionId, parent: Agent | undefined): ActivationObserver } /** - * Map a child result to the task outcome: completed carries final text, - * aborted is killed, and every other reason is failed without partial output. - * @param result - child terminal result. - * @returns outcome for the `ctx.tasks` registration. + * One residency epoch for a reconstructed continuable child Agent. It directly + * owns the published `AgentHandle`; the manager's private activation-owner + * scope is its structural Cordis owner. */ -function runOutcome(result: SubagentResult): TaskOutcome { - switch (result.stopReason) { - case 'completed': - return { status: 'completed', output: finalText(result.output) } - case 'aborted': - return { status: 'killed' } - case 'error': - case 'max-tokens': - case 'refusal': - return { status: 'failed', detail: result.stopReason } - // Merge-extensible reasons remain failures with their raw detail. - default: - return { status: 'failed', detail: String(result.stopReason) } +interface Activation { + /** The durable child this Activation is an epoch of. */ + readonly childId: SessionId + /** The provider name recorded in the durable descriptor. */ + readonly provider: string + /** The retained live Agent handle, disposed exactly once at settlement. */ + readonly handle: AgentHandle + /** + * Session ids of the child Activations this one owns. Because one Session has + * at most one live Activation, the id identifies the live child without + * another runtime-incarnation reference. Non-empty blocks settlement. + */ + readonly ownedChildren: Set + /** The lifecycle observer that emits this epoch's start and terminal edges. */ + readonly observer: ActivationObserver + /** + * The memoized disposal transaction. Presence IS the admission cutoff: it is + * assigned synchronously when disposal begins, so no delivery can join a + * handle being torn down, and a racing delivery awaits it before cold-resuming + * a new Activation. Every converging releaser shares this one teardown. + */ + disposal: Promise | undefined + /** Renewed whenever a settlement watcher must re-observe quiescence. */ + poke: PromiseWithResolvers +} + +/** + * Read one Activation's current disposal transaction. This indirection exists + * because a mutable field read inside a long-lived closure narrows to its + * last-seen value, which would flatten these genuine runtime checks to + * constants. + * @param activation - the Activation to inspect. + * @returns the in-flight or settled disposal, or `undefined` while resident. + */ +function disposalOf(activation: Activation): Promise | undefined { + return activation.disposal +} + +/** Whether one settlement attempt opened the disposal transaction. */ +type SettlementAttempt = + | { readonly settling: false } + | { readonly settling: true; readonly done: Promise } + +/** Serialize each durable child's delivery, release, and disposal. */ +class ChildLock { + private tails = new Map>() + + /** + * Run `operation` after every previously queued operation for `childId`. + * @param childId - the durable child whose operations are linearized. + * @param operation - the critical section to run in order. + * @returns the operation's own settlement. + */ + run(childId: SessionId, operation: () => Promise): Promise { + const previous = this.tails.get(childId) ?? Promise.resolve() + const result = previous.then(operation, operation) + // Absorb rejections in the chaining tail so one failed critical section + // cannot reject an unrelated later caller. + const tail = result.then(() => undefined, () => undefined) + this.tails.set(childId, tail) + void tail.then(() => { + if (this.tails.get(childId) === tail) this.tails.delete(childId) + }) + return result } } -/** Render infrastructure failure detail without hiding a durability diagnosis. */ -function runFailureDetail(error: unknown): string { - return error instanceof HarnessError && error.code === 'DURABILITY_FAILED' - ? error.message - : String(error) -} - /** - * Await the child result, dispose the run, then return its task outcome. Result - * and disposal failures become `failed`; when both fail, both details survive. - * @param run - live run to settle and release. - * @returns outcome after child resources are released. - */ -export async function settleRun(run: SubagentRun): Promise { - let outcome: TaskOutcome - try { - outcome = runOutcome(await run.result) - } catch (error: unknown) { - outcome = { status: 'failed', detail: runFailureDetail(error) } - } - try { - await run.dispose() - } catch (error: unknown) { - const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; ` - return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` } - } - return outcome -} - -/** Flatten a child's final output blocks to the task's final text. */ -function finalText(blocks: ContentBlock[]): string { - return blocks - .filter((block): block is Extract => block.type === 'text') - .map(block => block.text) - .join('') -} - -/** - * The continuable-subagent orchestration service. Tool schema and UI adapters - * are consumers of this one contract: parent and human messages route through - * {@link followup} and share one activation result and cancellation - * boundary, while foreground one-shot delegation keeps calling - * `ctx.subagents.start()` directly. + * The continuable-subagent orchestration service behind `ctx.subagents`. Tool + * schema and host adapters are consumers of this one contract; foreground + * one-shot delegation keeps calling `ctx.subagents.start()` and never enters + * this lifecycle. */ export class SubagentContinuationManager { - /** Child session id → its current activation. Process-local, never durable. */ - private activations = new Map() + /** Child session id → its live Activation. Process-local, never durable. */ + private activations = new Map() + private readonly locks = new ChildLock() + /** Structural Cordis owner of every Activation handle. */ + private readonly ownerCtx: Context + private draining = false constructor( private readonly ctx: Context, - private readonly startProvider: StartProvider, - private readonly resumeProvider: ResumeProvider, + private readonly host: ContinuationHost, ) { - // Terminal publication is one of the two removal conditions. The exact - // Task id pins the resolution to this activation, never a later same-child one. - ctx.tasks.onTaskDone((snapshot) => { - for (const activation of this.activations.values()) { - if (activation.taskId === snapshot.id) activation.terminal.resolve() - } - }) - // TaskService deliberately keeps producer Tasks alive across a - // follow-up-tool or producer reload, so this manager's disposal must not - // strand the activations it can no longer route to: cancel each one and - // await producer settlement (run disposal) before releasing the map. The - // effect-scoped onTaskDone listener above is already gone by then, so - // terminal publication is resolved here instead of waiting forever. - ctx.effect(() => async () => { - const active = [...this.activations.values()] - this.activations.clear() - for (const activation of active) { - activation.controller.abort('subagent continuation manager disposed') - activation.terminal.resolve() - } - await Promise.allSettled(active.map((activation) => { - /* v8 ignore next 2 -- TaskService invokes `run` synchronously before `start` returns; - * every retained activation has `done`, while registration failure removes it. */ - if (activation.done === undefined) return Promise.resolve() - return activation.done - })) - }, 'subagents.continuations()') + // Ordinary Cordis owner effects unwind in reverse registration order, which + // cannot express the dynamic child graph. Register the private scope's + // structural disposer FIRST and the drain SECOND, so reverse unwind invokes + // the drain before releasing the scope; a cleanup effect on the same scope + // as the Agent handles would let structural handle disposal bypass + // child-first ordering. + const scope = ctx.plugin(function activationOwner() {}) + this.ownerCtx = scope.ctx + ctx.effect(function* (this: SubagentContinuationManager) { + yield scope.dispose + yield () => this.drain() + }.bind(this), 'subagents.continuations()') } /** - * Start a continuable background child: allocate its stable session id, - * snapshot its durable descriptor, and register the initial activation's - * Task. A synchronous validation failure (a non-JSON descriptor input, - * missing persistence, Task preflight) throws without creating a Task; the - * method otherwise returns both identities immediately, without waiting for - * child publication or descriptor durability. Asynchronous startup failure - * settles the returned Task as `failed` (or `killed` when cancelled) after - * any published run is disposed, which can leave an unmaterialized child id - * that later by-id operations report as unavailable. - * @param spec - provider, Task label, and the delegation request. - * @returns the stable child id and the initial activation's Task id. + * Whether this manager still admits new materialization and delivery. Host + * teardown closes admission synchronously through {@link enterDraining}. + * @returns true once draining began. */ - startContinuable(spec: ContinuableStartSpec): ContinuableStart { + get isDraining(): boolean { + return this.draining + } + + /** + * Close admission synchronously: reject new creation, cold resume, and + * delivery so a host can drain the live Activation forest without racing new + * work. Idempotent. + */ + enterDraining(): void { + this.draining = true + } + + /** + * Read one durable child's live residency state. + * @param childId - the durable child session id. + * @returns its Activation state, or `undefined` when no Activation is live. + */ + activationState(childId: SessionId): ActivationState | undefined { + const activation = this.activations.get(childId) + if (activation === undefined) return undefined + return this.stateOf(activation) + } + + /** + * Start one continuable background child: reserve its durable identity, + * resolve the provider's detached creation spec, create the child Agent + * through the private activation-owner scope, establish any continuable-parent + * ownership, and submit the initial prompt. Resolves when inbox acceptance + * yields the message id — without waiting for the turn to start or for the + * message to reach the Session log. + * + * Every failure before that acceptance rejects without either id, disposing + * any created handle and rolling back the Activation and parent ownership. + * The caller signal owns lookup, materialization, and admission only until + * acceptance; afterwards the manager owns the Activation independently. + * @param spec - provider, delegation request, and caller cancellation. + * @returns the durable child id and the accepted initial prompt's message id. + */ + async startContinuable(spec: ContinuableStartSpec): Promise { + this.assertAdmitting() this.requirePersistence() - const childId = SessionId(randomUUID()) const request = spec.request - // Snapshot before Task creation: invalid descriptor JSON rejects the call - // with no Task, and the detached value is what reaches the child log. - const agentProvider = request.agentOptions?.provider ?? request.parent.options.provider - const agentModel = request.agentOptions?.model ?? request.parent.options.model + const parent = request.parent + const childId = SessionId(randomUUID()) + const childDepth = resolveChildDepth(parent, request.maxDepth) + // Snapshot before any await: invalid descriptor JSON rejects the call + // before a child exists, and the detached value is what reaches the log. + const agentProvider = request.agentOptions?.provider ?? parent.options.provider + const agentModel = request.agentOptions?.model ?? parent.options.model const descriptor = snapshotSubagentDescriptor({ provider: spec.provider, ...agentProvider !== undefined ? { agentProvider } : {}, @@ -242,293 +305,498 @@ export class SubagentContinuationManager { ...request.persona !== undefined ? { persona: request.persona } : {}, ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, }) - const taskId = this.startActivation(childId, spec.label, request.parent, signal => - this.startProvider(spec.provider, { - ...request, - signal, - continuation: { sessionId: childId, descriptor }, - })) - return { childId, taskId } + + const prepared = await this.host.prepareContinuable(spec.provider, { + sessionId: childId, + parent, + signal: spec.signal, + }) + spec.signal.throwIfAborted() + this.assertAdmitting() + + const lineageSeedLength = prepared.seed?.length ?? 0 + const seed = seedDescriptorTurn(childId, prepared.seed, descriptor) + const messageId = await this.locks.run(childId, async () => { + const activation = await this.materialize({ + childId, + provider: spec.provider, + parent, + seed, + meta: childSessionMeta(parent, childDepth, lineageSeedLength), + agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), + composition: { persona: request.persona, toolFilter: request.toolFilter }, + signal: spec.signal, + }) + return this.submit(activation, request.prompt, { kind: 'user' }, { kind: 'parent', agent: parent }) + }) + return { childId, messageId } } /** - * Follow up with a known continuable child: steer its running - * activation, or cold-resume the durable session into a fresh Task-backed - * activation. The two routes are reported distinctly so timing-dependent - * routing is observable. Rejection means the message was NOT delivered — in - * particular, losing a race with Task settlement does not fall through to - * cold resume within the same call; a later retry after Task terminal may - * start the next activation. The started Task owns descriptor lookup and - * direct-parent authorization (its AbortSignal exists before that lookup), - * so an unknown, foreign, or descriptor-less child settles the started Task - * as `failed` with a detail reporting the id as unavailable. - * @param parent - the live parent agent sending the message (model tool or - * human adapter); Task access is authorized by its session id. - * @param childId - the stable child session id. + * Deliver one later message to a known continuable child as its next FIFO + * turn. Routing depends only on Activation residency: a `running` Activation + * enqueues, a `waiting` one wakes the same Agent, and an absent one + * cold-resumes a new Activation from the persisted Session. The Agent inbox + * is the only queue, so parent and user messages share one observable order. + * + * The caller signal owns lookup, materialization, and admission only until + * inbox acceptance; afterwards the accepted turn cannot be cancelled through + * this service. + * @param authority - trusted parent or user authority for this delivery. + * @param childId - the durable child session id. * @param content - the user-role content to deliver. - * @param options - caller attribution and cancellation. During live delivery, - * abort cancels the shared activation and rejects only after quiescence. - * @returns whether the content `steered` the existing Task or `started` a new one. + * @param options - durable provenance and caller cancellation. + * @returns the accepted message's inbox id. + * @throws when authority, availability, or admission rejects the delivery. */ async followup( - parent: Agent, + authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, - ): Promise { - this.assertOwnership(childId) - const activation = this.activations.get(childId) - if (activation !== undefined) { - return { - route: 'steered', - taskId: await this.steerActivation( - activation, - parent, - childId, - content, - options.source, - options.signal, - ), - } - } - return { - route: 'started', - taskId: this.resumeActivation(parent, childId, content, options.source), + ): Promise { + this.assertAdmitting() + while (true) { + const live = await this.locks.run(childId, async () => { + const activation = this.activations.get(childId) + if (activation === undefined) return this.coldResume(authority, childId, content, options) + // A delivery that arrives after the disposal transaction began must not + // reach a handle being torn down; wait for release, then cold-resume. + if (activation.disposal !== undefined) { + return activation.disposal.then(() => undefined, () => undefined) + } + await this.authorizeLive(authority, activation) + return this.submit(activation, content, options.source, authority) + }) + if (live !== undefined) return live + // The racing disposal completed; retry admission, which now cold-resumes. + this.assertAdmitting() + options.signal.throwIfAborted() } } /** - * Synchronous ownership compare before any by-id routing: a live registry - * Agent outside the association — or different from the associated run's - * agent — was started by something else. Fail instead of adopting an idle - * Agent or attaching an untracked turn. + * Dispose every live Activation forest child-first and await all handles. + * Sibling branches drain independently: one failure is recorded but never + * prevents the remaining handles from being attempted, and the aggregate + * rejects only after every branch settles. + * @returns once every snapshotted Activation released its handle. + * @throws an aggregate error when any branch failed to release. */ - private assertOwnership(childId: SessionId): void { - const live = this.ctx.agents.get(childId) - if (live === undefined) return - const activation = this.activations.get(childId) - if (activation === undefined) { + async drain(): Promise { + this.enterDraining() + // Snapshot roots after closing admission: a root is an Activation no live + // Activation owns, so disposing roots recurses child-first into the forest. + const owned = new Set() + for (const activation of this.activations.values()) { + for (const child of activation.ownedChildren) owned.add(child) + } + const roots = [...this.activations.values()].filter(activation => !owned.has(activation.childId)) + const failures = await Promise.all(roots.map(async (activation) => { + try { + await this.dispose(activation) + return undefined + } catch (error: unknown) { + return error + } + })) + const reasons = failures.filter(failure => failure !== undefined) + if (reasons.length > 0) { + throw new SubagentError( + `continuable subagent teardown failed for ${reasons.length} activation(s): ` + + reasons.map(reason => errorChain(reason)).join('; '), + 'ACTIVATION_TEARDOWN_FAILED', + ) + } + } + + /** Reject new admission once the host or manager began draining. */ + private assertAdmitting(): void { + if (this.draining) { + throw new SubagentError( + 'continuable subagents are draining; the operation was not admitted', + 'DRAINING', + ) + } + } + + /** + * Derive residency from Agent quiescence and the owned-child set. `running` + * covers an active admission, an open turn, or waking inbox work. + */ + private stateOf(activation: Activation): ActivationState { + if (activation.handle.agent.status === 'running') return 'running' + if (activation.ownedChildren.size > 0) return 'waiting' + return 'settled' + } + + /** + * Cold-resume a persisted child: load and authorize its Session, fold the + * generic descriptor, create the Activation through `ctx.agents.resume()`, + * and submit the waiting turn. This never dispatches through a subagent + * provider — the persisted Session already holds the initial prefix and the + * descriptor is the whole reconstruction input. + */ + private async coldResume( + authority: SubagentAuthority, + childId: SessionId, + content: ContentBlock[], + options: SubagentFollowupOptions, + ): Promise { + const persistence = this.requirePersistence() + let loaded: Awaited> + try { + loaded = await persistence.load(childId) + } catch (error: unknown) { + throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) + } + // The persistence seam takes no signal; recheck before any child work. + options.signal.throwIfAborted() + this.assertAdmitting() + // Authorize the persisted header before folding: only the durable child's + // direct parent — or the host user — may continue it. + this.authorizeLineage(authority, childId, loaded.meta.parentSession) + // Fold only the child's own suffix: a fork seed replays the parent's log, + // which may carry an ANCESTOR's descriptor when the parent is itself a + // continuable child. + const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0)) + if (descriptor === undefined) { + throw new SubagentError( + `subagent "${childId}" has no supported continuation state and cannot be resumed; ` + + 'do not retry send_message with this id', + 'NOT_RESUMABLE', + ) + } + const activation = await this.materialize({ + childId, + provider: descriptor.provider, + parent: authority.kind === 'parent' ? authority.agent : undefined, + resume: true, + agentOptions: { + ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, + ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, + }, + composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, + signal: options.signal, + }) + return this.submit(activation, content, options.source, authority) + } + + /** + * Create or resume the child Agent through the private activation-owner + * scope, install the handle in a fresh Activation, and register ownership on + * a continuation-managed parent. Rejection leaves no Activation, no handle, + * and no ownership membership. + */ + private async materialize(inputs: { + childId: SessionId + provider: string + parent: Agent | undefined + resume?: boolean + seed?: readonly SessionEvent[] + meta?: NonNullable + agentOptions: AgentOptions + composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } + signal: AbortSignal + }): Promise { + const { childId, provider, parent } = inputs + if (this.activations.has(childId)) { + throw new SubagentError( + `subagent "${childId}" already has a live activation; the message was not delivered`, + 'ACTIVATION_CONFLICT', + ) + } + if (this.ctx.agents.get(childId) !== undefined) { throw new SubagentError( `subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`, 'OWNERSHIP_CONFLICT', ) } - if (activation.run !== undefined && activation.run.localAgent !== live) { - throw new SubagentError( - `subagent "${childId}" registry agent is not the associated activation's agent; the message was not delivered`, - 'OWNERSHIP_CONFLICT', - ) - } - } - - /** Deliver to the running activation's Task through confirmed live steering. */ - private async steerActivation( - activation: ActiveActivation, - parent: Agent, - childId: SessionId, - message: ContentBlock[], - source: MessageSource, - signal: AbortSignal, - ): Promise { - const taskId = activation.taskId - /* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */ - if (taskId === undefined) { - throw new SubagentError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') - } - // Owner-session authorization plus the live status for admission. - const snapshot = this.ctx.tasks.get(taskId, parent) - if (snapshot.status !== 'running') { - throw new SubagentError( - `subagent "${childId}" task ${taskId} is ${snapshot.status}; the message was not delivered ` - + '— retry after it settles to start the next activation', - 'NOT_DELIVERED', - ) - } - const run = activation.run - if (run === undefined) { - throw new SubagentError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') - } - if (run.steer === undefined) { - throw new SubagentError( - `subagent "${childId}" provider does not accept live delivery; the message was not delivered`, - 'NOT_DELIVERED', - ) - } - const cancelActivation = (): void => { - activation.controller.abort(signal.reason) - } - signal.addEventListener('abort', cancelActivation, { once: true }) - if (signal.aborted) { - cancelActivation() - signal.removeEventListener('abort', cancelActivation) - return await this.cancelledLiveDelivery(activation, childId) - } + inputs.signal.throwIfAborted() + const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) } + const observer = this.host.observeActivation(provider, childId, parent) + let handle: AgentHandle try { - await run.steer(message, source) + handle = inputs.resume === true + ? await this.ownerCtx.agents.resume({ + resumeSessionId: childId, + agentOptions: inputs.agentOptions, + signal: inputs.signal, + setup, + }) + : await this.ownerCtx.agents.create({ + sessionId: childId, + ...inputs.meta !== undefined ? { meta: inputs.meta } : {}, + ...inputs.seed !== undefined ? { seed: inputs.seed } : {}, + agentOptions: inputs.agentOptions, + signal: inputs.signal, + setup, + }) } catch (error: unknown) { - try { - signal.throwIfAborted() - } catch { - return await this.cancelledLiveDelivery(activation, childId, error) - } - // Confirmed steering lost the race with request admission. Deliberately no - // cold-resume fallback here: that would attach the message to a turn the - // caller did not observe. - throw new SubagentError( - `subagent "${childId}" stopped before delivery; the message was not delivered`, - 'NOT_DELIVERED', - { cause: error }, - ) - } finally { - signal.removeEventListener('abort', cancelActivation) + // Agent creation provides rollback before handle transfer, so nothing + // outlives this rejection; report the epoch that never became resident. + observer.settle(undefined, error) + throw error } - return taskId + + const activation: Activation = { + childId, + provider, + handle, + ownedChildren: new Set(), + observer, + disposal: undefined, + poke: Promise.withResolvers(), + } + // After transfer, any failure must dispose the created handle, remove the + // Activation, and roll back parent ownership before rejecting. + this.activations.set(childId, activation) + try { + inputs.signal.throwIfAborted() + this.assertAdmitting() + this.acquireOwnership(parent, childId) + } catch (error: unknown) { + // Roll the transfer back completely: the Activation leaves the map, the + // parent's ownership membership is released, and the created handle is + // disposed before this rejection surfaces. + this.activations.delete(childId) + this.releaseOwnership(childId) + activation.disposal = (async () => { + try { + await handle.dispose() + } finally { + observer.settle(handle.agent, error) + } + })() + await activation.disposal.catch(() => undefined) + throw error + } + // Resident: publish the start edge before any turn can run, so observers + // see this epoch before its first request. + observer.start() + this.watchSettlement(activation) + return activation } - /** Reject a cancelled live delivery only after its shared activation is quiescent. */ - private async cancelledLiveDelivery( - activation: ActiveActivation, - childId: SessionId, - cause?: unknown, - ): Promise { - /* v8 ignore if -- a published run implies the producer assigned `done` before its provider await resolved. */ - if (activation.done === undefined) { - throw new Error('published subagent activation has no settlement promise') + /** + * Register the child in a continuation-managed parent's owned set before the + * child can run, so that parent cannot settle while the child is live. A + * top-level or other non-continuation Agent has no Activation and stays + * outside the waiting graph. + */ + private acquireOwnership(parent: Agent | undefined, childId: SessionId): void { + if (parent === undefined) return + const parentActivation = this.activations.get(parent.id) + if (parentActivation === undefined) return + if (parentActivation.disposal !== undefined) { + throw new SubagentError( + `subagent parent "${parent.id}" is being disposed; the child was not established`, + 'ACTIVATION_CLOSING', + ) } - await activation.done - throw new SubagentError( - `subagent "${childId}" live delivery was cancelled; the message was not delivered`, - 'CANCELLED', - cause === undefined ? undefined : { cause }, + parentActivation.ownedChildren.add(childId) + } + + /** Remove one child from its live owner's set and let that owner re-check settlement. */ + private releaseOwnership(childId: SessionId): void { + for (const candidate of this.activations.values()) { + if (candidate.ownedChildren.delete(childId)) this.wake(candidate) + } + } + + /** Let a settlement watcher re-observe quiescence after ownership or inbox changes. */ + private wake(activation: Activation): void { + activation.poke.resolve() + activation.poke = Promise.withResolvers() + } + + /** + * Submit one message as the child's next FIFO turn and return its accepted + * inbox id. Acceptance is the operation's success boundary; the manager owns + * the Activation independently afterwards. + */ + private submit( + activation: Activation, + content: ContentBlock[], + source: MessageSource, + authority: SubagentAuthority, + ): MessageId { + // Parent-originated delivery keeps the parent live through ownership, so + // establish it before the message can enter the child's inbox. + if (authority.kind === 'parent') this.acquireOwnership(authority.agent, activation.childId) + const message = createUserMessage({ content, source }) + activation.handle.agent.followup(message) + // Accepted waking work keeps this Activation live until whenIdle() observes + // the complete waking suffix. + this.wake(activation) + return message.id + } + + /** + * Authorize delivery to a live Activation. A parent must be the exact live + * direct parent recorded in the child's durable header. + */ + private async authorizeLive(authority: SubagentAuthority, activation: Activation): Promise { + await Promise.resolve() + this.authorizeLineage( + authority, + activation.childId, + activation.handle.agent.session.header.parentSession, ) } /** - * Cold-resume a persisted child into a fresh Task-backed activation. The - * Task owns its `AbortController` before descriptor lookup: the load, - * direct-parent authorization, and descriptor fold run inside the - * activation, with cancellation rechecked after the un-signalled - * persistence await so an early `task_kill` prevents any later child work. + * Authorize one operation against the durable direct-parent lineage. User + * authority may continue any child without loading its parent; parent + * authority requires the exact live direct parent. Other agents, ancestors, + * teams, and workflows remain rejected until an explicit authority protocol + * exists. */ - private resumeActivation( - parent: Agent, + private authorizeLineage( + authority: SubagentAuthority, childId: SessionId, - message: ContentBlock[], - source: MessageSource, - ): TaskId { - const persistence = this.requirePersistence() - return this.startActivation(childId, resumeLabel(message), parent, async (signal) => { - let loaded: Awaited> - try { - loaded = await persistence.load(childId) - } catch (error: unknown) { - throw new SubagentError( - `subagent "${childId}" is unavailable`, - 'NOT_RESUMABLE', - { cause: error }, - ) - } - // The persistence seam takes no signal; recheck before any child work. - if (signal.aborted) throw new SubagentError('subagent resume was cancelled during lookup', 'CANCELLED') - // Authorize the persisted header before folding: only the direct parent - // recorded at creation may continue this child. - if (loaded.meta.parentSession !== parent.id) { - throw new SubagentError( - `subagent "${childId}" belongs to another parent session`, - 'UNAUTHORIZED', - ) - } - // Fold only the child's own suffix: a fork seed replays the parent's - // log, which may carry an ANCESTOR's descriptor when the parent is - // itself a continuable child. - const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0)) - if (descriptor === undefined) { - throw new SubagentError( - `subagent "${childId}" has no supported continuation state and cannot be resumed; ` - + 'do not retry send_message with this id', - 'NOT_RESUMABLE', - ) - } - return this.resumeProvider({ - sessionId: childId, - prompt: message, - source, - parent, - signal, - descriptor, - }) - }) + parentSession: SessionId | undefined, + ): void { + if (authority.kind === 'user') return + const parent = authority.agent + if (this.ctx.agents.get(parent.id) !== parent) { + throw new SubagentError( + `subagent "${childId}" delivery requires the exact live parent agent`, + 'UNAUTHORIZED', + ) + } + if (parentSession !== parent.id) { + throw new SubagentError(`subagent "${childId}" belongs to another parent session`, 'UNAUTHORIZED') + } } /** - * Install the activation association, register its Task, and bind the two - * removal conditions. The association is installed before any persistence - * or provider await — the producer body runs synchronously up to its first - * await — and removed only after run disposal (the producer settled) and - * Task terminal publication. This synchronous install admits one activation - * per child in this process; a competing untracked publication still loses - * at the Agent registry collision boundary inside the provider. + * Follow one Activation to settlement: wait for Agent quiescence, then for + * every owned child to complete disposal, and dispose the handle once both + * hold. A `next-turn` delivered while `waiting` wakes the same Agent and + * returns it to `running`, so this re-observes rather than settling early. */ - private startActivation( - childId: SessionId, - label: string, - owner: Agent, - begin: (signal: AbortSignal) => Promise, - ): TaskId { - const activation: ActiveActivation = { - taskId: undefined, - run: undefined, - controller: new AbortController(), - done: undefined, - terminal: Promise.withResolvers(), - } - this.activations.set(childId, activation) - let taskId: TaskId - try { - taskId = this.ctx.tasks.start({ - kind: 'subagent', - label, - owner, - run: (): TaskHooks => { - const done = (async (): Promise => { - try { - const run = await begin(activation.controller.signal) - activation.run = run - return await settleRun(run) - } catch (error: unknown) { - // A pre-publication abort rejects only after the provider's - // creation transaction rolled back to quiescence, so recording - // `killed` here honors the settlement-after-rollback contract. - return activation.controller.signal.aborted - ? { status: 'killed' } - : { status: 'failed', detail: String(error) } - } - })() - activation.done = done - void Promise.allSettled([done, activation.terminal.promise]).then(() => { - /* v8 ignore else -- service teardown clears the map while a producer is still settling. */ - if (this.activations.get(childId) === activation) this.activations.delete(childId) - }) - return { - cancel: (reason?: string) => { - // Cancellation targets the whole activation: every message that - // joined this turn shares the `killed` outcome. - activation.controller.abort(reason ?? 'subagent activation killed') - }, - done, - // No readOutput: the child session owns intermediate detail. + private watchSettlement(activation: Activation): void { + void (async () => { + while (disposalOf(activation) === undefined) { + const poked = activation.poke.promise + await Promise.race([activation.handle.agent.whenIdle(), poked]) + if (disposalOf(activation) !== undefined) return + // Re-check settlement INSIDE the child lock and begin disposal in the + // same critical section, so a concurrent delivery either wins admission + // before the transaction opens or waits for release and cold-resumes. + // Deciding outside the lock would let a delivery observe a not-yet + // resident handle that this watcher is already about to tear down. + const settling = await this.locks.run(activation.childId, () => { + if (disposalOf(activation) !== undefined || this.stateOf(activation) !== 'settled') { + return Promise.resolve({ settling: false }) } - }, - }) + // `dispose()` assigns its memoized transaction synchronously, so + // admission is closed before this critical section releases. + return Promise.resolve({ settling: true, done: this.dispose(activation) }) + }) + if (!settling.settling) { + // Still running, or waiting on descendants: re-observe after the next + // accepted message or ownership release. + if (activation.handle.agent.status !== 'running') await poked + continue + } + try { + await settling.done + } catch (error: unknown) { + this.ctx.logger.warn( + `subagent "${activation.childId}" activation teardown failed: ${errorChain(error)}`, + ) + } + return + } + })() + } + + /** + * Release one Activation child-first: dispose owned children, checkpoint + * durability, dispose the handle, and release parent ownership. Memoized, so + * host shutdown, manager unload, child release, and normal settlement + * converge on one teardown. + * + * A failed final checkpoint is reported but never prevents handle disposal or + * ownership release, because retaining a failed child would permanently pin + * its ancestors in `waiting`. + */ + private dispose(activation: Activation): Promise { + return (activation.disposal ??= (async () => { + // The memoized assignment above already closed admission for this child: + // no caller may send to a handle after its disposal transaction begins. + this.wake(activation) + const { childId } = activation + let failure: Error | undefined + try { + // Child-first: every owned child must complete disposal before this + // handle is released. + const children = [...activation.ownedChildren] + .map(child => this.activations.get(child)) + .filter((child): child is Activation => child !== undefined) + const childFailures = await Promise.all(children.map(async (child) => { + try { + await this.dispose(child) + return undefined + } catch (error: unknown) { + return error + } + })) + const reasons = childFailures.filter(reason => reason !== undefined) + if (reasons.length > 0) { + failure = new SubagentError( + `subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`, + 'ACTIVATION_TEARDOWN_FAILED', + ) + } + const durability = await this.checkpoint(activation) + failure ??= durability + } finally { + this.activations.delete(childId) + try { + await activation.handle.dispose() + } catch (error: unknown) { + failure ??= new SubagentError( + `subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`, + 'ACTIVATION_TEARDOWN_FAILED', + { cause: error }, + ) + } finally { + // Release ownership even on failure: a retained failed child would + // pin its ancestors in `waiting` forever. + this.releaseOwnership(childId) + activation.observer.settle(activation.handle.agent, failure) + } + } + if (failure !== undefined) throw failure + })()) + } + + /** + * Request the final durability checkpoint. Only `true` confirms durability; + * `false` and rejection both report `DURABILITY_FAILED` so the persisted + * child state is known to be possibly missing or stale on a later resume. + */ + private async checkpoint(activation: Activation): Promise { + const child = activation.handle.agent + try { + const participated = await child.ctx.sessions.flush(child.session) + if (participated) return undefined + return new SubagentError( + `subagent "${activation.childId}" required durability checkpoint has no registered listener; ` + + 'the latest child state was not confirmed persisted and may be unavailable or stale on resume', + 'DURABILITY_FAILED', + ) } catch (error: unknown) { - // Task preflight failed; nothing started, so the install rolls back. - this.activations.delete(childId) - throw error + return new SubagentError( + `subagent "${activation.childId}" durability checkpoint failed; the latest child state was not ` + + `confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`, + 'DURABILITY_FAILED', + { cause: error }, + ) } - // Same synchronous frame as the install: an observer that can run at all - // runs after this assignment. - activation.taskId = taskId - return taskId } /** Resolve the persistence service continuable children require, or fail loud. */ @@ -544,11 +812,5 @@ export class SubagentContinuationManager { } } -/** Derive a resumed activation's Task label from its message. */ -function resumeLabel(message: ContentBlock[]): string { - const text = finalText(message).trim().replace(/\s+/g, ' ') - if (text.length === 0) return 'subagent follow-up' - return text.length > 80 ? `${text.slice(0, 79)}…` : text -} - +export type { SubagentDescriptorData } export default SubagentContinuationManager diff --git a/packages/subagent/subagent/src/depth.ts b/packages/subagent/subagent/src/depth.ts new file mode 100644 index 0000000000..d9fabab860 --- /dev/null +++ b/packages/subagent/subagent/src/depth.ts @@ -0,0 +1,51 @@ +/** + * Delegation-depth accounting: the recursion budget a parent passes to its + * children. Kept apart from the service so composition helpers can read it + * without importing the registry. + * + * @module @deepseek-ai/dsh-subagent/depth + */ + +import type { Agent } from '@deepseek-ai/dsh-agent' + +declare module '@deepseek-ai/dsh-agent' { + interface AgentOptions { + /** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */ + subagentDepth?: number + } +} + +/** + * Read an agent's delegation depth, treating absence as top-level depth zero. + * The persisted session header is authoritative and monotone: runtime + * `AgentOptions.subagentDepth` may DEEPEN the count but can never lower it — + * a resumed child arrives with fresh options, and counting it from zero would + * let it delegate as if it were top-level. + * @param agent - the agent whose header and options carry the depth. + * @returns its non-negative safe-integer depth. + * @throws if the runtime `AgentOptions.subagentDepth` is not a non-negative safe integer. + */ +export function delegationDepthOf(agent: Agent): number { + const runtime = agent.options.subagentDepth + if (runtime !== undefined && (!Number.isSafeInteger(runtime) || runtime < 0 || Object.is(runtime, -0))) { + throw new TypeError('agent subagentDepth must be a non-negative safe integer') + } + // The header value was validated at the session boundary (creation and + // persistence load both construct through the store). + return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0) +} + +/** + * Reject a recursion cap that cannot represent an exact delegation depth. + * @param maxDepth - the optional runtime value to validate. + */ +export function assertSubagentMaxDepth(maxDepth: unknown): void { + if (maxDepth !== undefined && ( + typeof maxDepth !== 'number' + || !Number.isSafeInteger(maxDepth) + || maxDepth < 0 + || Object.is(maxDepth, -0) + )) { + throw new TypeError('subagent maxDepth must be a non-negative safe integer') + } +} diff --git a/packages/subagent/subagent/src/descriptor-seed.ts b/packages/subagent/subagent/src/descriptor-seed.ts new file mode 100644 index 0000000000..836b40009d --- /dev/null +++ b/packages/subagent/subagent/src/descriptor-seed.ts @@ -0,0 +1,31 @@ +/** + * Seeding of a continuable child's durable descriptor event: the model-hidden + * record of the child's declared composition before its first request, so a + * later cold resume can reconstruct it from its own log. + * + * @module @deepseek-ai/dsh-subagent/descriptor-seed + */ + +import { Session } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SubagentDescriptorData } from './descriptor.ts' + +/** + * Build the child's creation seed: any inherited parent-history prefix followed + * by one model-hidden, between-turn `descriptor` event. Staging through a + * `Session` assigns the sequence number and enforces the same lossless-JSON + * rules the durable log does. + * @param childId - the reserved child session id the staged log belongs to. + * @param seed - the inherited completed-turn prefix, or `undefined` for a fresh child. + * @param descriptor - the snapshotted composition record to persist. + * @returns the complete seed events, contiguous from sequence zero. + */ +export function seedDescriptorTurn( + childId: SessionId, + seed: readonly SessionEvent[] | undefined, + descriptor: SubagentDescriptorData, +): SessionEvent[] { + const staged = new Session(childId, seed) + staged.append('subagent/descriptor', descriptor) + return [...staged.events] +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 023ac1fe26..0bfebc8cf5 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -13,11 +13,13 @@ * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. * - * Public operations express caller intent: `start` returns one ready owned run, - * `startContinuable` starts a Task-backed durable child, and `followup` routes - * later content without exposing whether the child is live. Provider resume - * dispatch stays private because only the continuation manager holds the - * resolved descriptor and authorization facts. + * Public operations express caller intent: `start` returns one ready owned + * one-shot run, `startContinuable` establishes a durable continuable child, and + * `followup` delivers later content without exposing whether the child is + * resident. Continuable children never become a {@link SubagentRun}: the + * continuation manager holds their `AgentHandle` directly and orders every turn + * through the child's own inbox, so providers contribute only the detached + * creation spec and see no handle, turn, or teardown. * * Same-process providers are trusted typed collaborators. Requests, provider * descriptors, results, and lifecycle payloads are borrowed immutable values; @@ -32,36 +34,38 @@ import { Context, Service } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { + ContinuableCreateRequest, + ContinuableCreateSpec, SubagentCapabilities, SubagentProvider, - SubagentProviderResumeRequest, - SubagentProviderStartRequest, SubagentResult, SubagentRun, SubagentStartRequest, } from './types.ts' import { SubagentRunId } from './types.ts' import { SubagentError } from './error.ts' +import { assertSubagentMaxDepth } from './depth.ts' import SubagentContinuationManager from './continuation.ts' import type { + ActivationObserver, + ActivationState, ContinuableStart, ContinuableStartSpec, + SubagentAuthority, SubagentFollowupOptions, - SubagentFollowupResult, } from './continuation.ts' export * from './out-of-process.ts' export { SubagentRunId } from './types.ts' export type { + ContinuableCreateRequest, + ContinuableCreateSpec, SubagentCapabilities, - SubagentContinuation, SubagentProvider, - SubagentProviderResumeRequest, - SubagentProviderStartRequest, SubagentResult, SubagentRun, SubagentStartRequest, @@ -74,58 +78,28 @@ export { SUBAGENT_DESCRIPTOR_VERSION, } from './descriptor.ts' export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts' +export { seedDescriptorTurn } from './descriptor-seed.ts' export { SubagentError } from './error.ts' -export { settleRun } from './continuation.ts' +export { settleRun } from './run-settlement.ts' +export { assertSubagentMaxDepth, delegationDepthOf } from './depth.ts' +export { + applyChildComposition, + childSessionMeta, + resolveChildAgentOptions, + resolveChildDepth, + SubagentDepthError, +} from './child-agent.ts' +export type { ChildComposition } from './child-agent.ts' export type { + ActivationObserver, + ActivationState, ContinuableStart, ContinuableStartSpec, CoordinatorMessageSource, + SubagentAuthority, SubagentFollowupOptions, - SubagentFollowupResult, } from './continuation.ts' -declare module '@deepseek-ai/dsh-agent' { - interface AgentOptions { - /** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */ - subagentDepth?: number - } -} - -/** - * Read an agent's delegation depth, treating absence as top-level depth zero. - * The persisted session header is authoritative and monotone: runtime - * `AgentOptions.subagentDepth` may DEEPEN the count but can never lower it — - * a resumed child arrives with fresh options, and counting it from zero would - * let it delegate as if it were top-level. - * @param agent - the agent whose header and options carry the depth. - * @returns its non-negative safe-integer depth. - * @throws if the runtime `AgentOptions.subagentDepth` is not a non-negative safe integer. - */ -export function delegationDepthOf(agent: Agent): number { - const runtime = agent.options.subagentDepth - if (runtime !== undefined && (!Number.isSafeInteger(runtime) || runtime < 0 || Object.is(runtime, -0))) { - throw new TypeError('agent subagentDepth must be a non-negative safe integer') - } - // The header value was validated at the session boundary (creation and - // persistence load both construct through the store). - return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0) -} - -/** - * Reject a recursion cap that cannot represent an exact delegation depth. - * @param maxDepth - the optional runtime value to validate. - */ -export function assertSubagentMaxDepth(maxDepth: unknown): void { - if (maxDepth !== undefined && ( - typeof maxDepth !== 'number' - || !Number.isSafeInteger(maxDepth) - || maxDepth < 0 - || Object.is(maxDepth, -0) - )) { - throw new TypeError('subagent maxDepth must be a non-negative safe integer') - } -} - declare module 'cordis' { interface Context { subagents: SubagentService @@ -195,19 +169,18 @@ export interface SubagentRunEndInfo { readonly lastAssistantMessage?: ContentBlock[] } -/** Named provider registry with raw and Task-backed continuation operations. */ +/** Named provider registry with one-shot runs and continuable-child operations. */ export class SubagentService extends Service { private providers = new Map() private continuations: SubagentContinuationManager | undefined constructor(ctx: Context) { super(ctx, 'subagents') - ctx.inject(['tasks', 'agents'], (childCtx: Context) => { - const manager = new SubagentContinuationManager( - childCtx, - (name, request) => this.startProvider(name, request), - request => this.resumeProvider(request), - ) + ctx.inject(['agents'], (childCtx: Context) => { + const manager = new SubagentContinuationManager(childCtx, { + prepareContinuable: (name, request) => this.prepareContinuable(name, request), + observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent), + }) this.continuations = manager childCtx.effect(() => () => { /* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */ @@ -217,34 +190,64 @@ export class SubagentService extends Service { } /** - * Start one durable continuable child through a Task-backed initial - * activation. - * @param spec - provider, Task label, and delegation request. - * @returns the stable child id and initial activation Task id. + * Establish one durable continuable child and deliver its initial prompt. + * Resolves when the child's inbox accepts that prompt, without waiting for the + * turn to start or for the message to reach the Session log; any earlier + * failure rejects with no ids and rolls back the child entirely. + * @param spec - provider, delegation request, and caller cancellation. + * @returns the durable child id and the accepted prompt's message id. + * @throws when continuation services are unavailable or materialization fails. */ - startContinuable(spec: ContinuableStartSpec): ContinuableStart { + startContinuable(spec: ContinuableStartSpec): Promise { return this.requireContinuations().startContinuable(spec) } /** - * Follow up with a continuable child. A live child is steered and fulfillment - * confirms request admission; an idle child immediately returns a fresh Task - * whose descriptor lookup, authorization, and cold resume may later fail. - * @param parent - live direct parent authorizing the operation. + * Deliver one later message to a continuable child as its next FIFO turn. A + * resident child's Agent inbox accepts it directly (waking a `waiting` + * Activation), while an absent one is cold-resumed from its persisted + * Session. The Agent inbox is the only queue, so parent and user messages + * share one observable order. + * @param authority - trusted parent or user authority for this delivery. * @param childId - durable child session id. * @param content - user-role content to deliver. - * @param options - durable attribution and caller cancellation; aborting a - * live-delivery wait cancels the shared activation and awaits quiescence. - * @returns the existing steered Task or newly started Task. - * @throws when continuation services are unavailable or live delivery is not admitted. + * @param options - durable provenance and caller cancellation, which stops the + * operation only before inbox acceptance. + * @returns the accepted message's inbox id. + * @throws when continuation services are unavailable, authority is rejected, + * or the message was not admitted. */ followup( - parent: Agent, + authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, - ): Promise { - return this.requireContinuations().followup(parent, childId, content, options) + ): Promise { + return this.requireContinuations().followup(authority, childId, content, options) + } + + /** + * Read one durable child's live residency state. + * @param childId - durable child session id. + * @returns its Activation state, or `undefined` when no Activation is live. + * @throws when continuation services are unavailable. + */ + activationState(childId: SessionId): ActivationState | undefined { + return this.requireContinuations().activationState(childId) + } + + /** + * Close continuable admission synchronously, then dispose every live + * Activation forest child-first. A host calls this before disposing top-level + * agents so no descendant outlives the runtime that owns its teardown. + * @returns once every live Activation released its `AgentHandle`. + * @throws an aggregate error after all branches settle when any failed. + */ + async drainContinuable(): Promise { + const manager = this.continuations + // Absent continuation services means nothing was ever materialized. + if (manager === undefined) return + await manager.drain() } /** @@ -298,40 +301,32 @@ export class SubagentService extends Service { * @param request - child prompt, parent, signal, and optional capabilities. * @returns the ready holder-owned run. */ - async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise { - return this.startProvider(name, request) - } - - /** Validate and dispatch one ordinary or service-resolved provider start. */ - private async startProvider( - name: string, - request: SubagentProviderStartRequest, - ): Promise { + async start(name: string, request: SubagentStartRequest): Promise { const provider = this.expectProvider(name) this.assertCapabilities(provider, request) assertSubagentMaxDepth(request.maxDepth) if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema) - if (request.continuation !== undefined && provider.resume === undefined) { - throw new SubagentError( - `subagent provider "${provider.name}" does not support continuable children (no resume capability)`, - 'UNSUPPORTED_CAPABILITY', - ) - } - return this.observeRun(name, request.parent, await provider.start(request)) } - /** Dispatch one authorized provider resume and observe its run lifecycle. */ - private async resumeProvider(request: SubagentProviderResumeRequest): Promise { - const name = request.descriptor.provider + /** + * Resolve one provider's detached continuable-creation contribution. Method + * presence on the provider IS the capability, so a provider without it is + * rejected before the manager reserves any child resources. + */ + private async prepareContinuable( + name: string, + request: ContinuableCreateRequest, + ): Promise { const provider = this.expectProvider(name) - if (provider.resume === undefined) { + if (provider.prepareContinuable === undefined) { throw new SubagentError( - `subagent provider "${provider.name}" does not support resuming persisted children (no resume capability)`, + `subagent provider "${provider.name}" does not support continuable children ` + + '(no prepareContinuable capability)', 'UNSUPPORTED_CAPABILITY', ) } - return this.observeRun(name, request.parent, await provider.resume(request)) + return provider.prepareContinuable(request) } /** Look up a provider for dispatch or fail loud. */ @@ -354,6 +349,41 @@ export class SubagentService extends Service { return this.continuations } + /** + * Emit the start/end lifecycle pair for one continuable Activation's + * residency epoch. Observers see the same vocabulary as a one-shot run, so a + * child's start and settlement remain observable without exposing whether the + * manager materialized, woke, or cold-resumed it. Creation failure before + * residency reports only the terminal edge. + */ + private observeActivation( + provider: string, + childId: SessionId, + parent: Agent | undefined, + ): ActivationObserver { + const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true } + let started = false + let settled = false + return { + start: (): void => { + started = true + this.emitLifecycle('subagent/start', identity, parent) + }, + settle: (child: Agent | undefined, failure: unknown): void => { + // A failure before residency has no start edge to pair, and inventing + // one would report a lifecycle the child never had. + if (settled || !started) return + settled = true + const output = failure === undefined ? lastAssistantOutput(child) : undefined + this.emitLifecycle('subagent/end', { + ...identity, + stopReason: failure === undefined ? 'completed' : 'error', + ...output === undefined ? {} : { lastAssistantMessage: output }, + }, parent) + }, + } + } + /** Emit the start/end lifecycle pair for one accepted run and return it. */ private observeRun(name: string, parent: Agent, run: SubagentRun): SubagentRun { const runId = SubagentRunId(randomUUID()) @@ -385,14 +415,16 @@ export class SubagentService extends Service { * Emit lifecycle events with per-listener synchronous and asynchronous * exception containment. Payloads are borrowed immutable values. */ - private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void - private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void + private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent | undefined): void + private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent | undefined): void private emitLifecycle(name: 'subagent/provider-removed', info: string): void private emitLifecycle( name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed', info: SubagentRunInfo | SubagentRunEndInfo | string, - parent?: Agent, + parent?: Agent , ): void { + // A user-resumed continuable child has no delegating parent to key the + // carrier by, so its lifecycle reaches unscoped listeners globally. const dispatchArgs: unknown[] = parent === undefined ? [name, info] : [scopeTarget(this, parent), name, info] @@ -427,6 +459,18 @@ export class SubagentService extends Service { } } +/** + * The child's last assistant message content, for one Activation's terminal + * lifecycle edge. Absent when no assistant message reached the log. + */ +function lastAssistantOutput(child: Agent | undefined): ContentBlock[] | undefined { + if (child === undefined) return undefined + const message = child.session.events.findLast( + (event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message', + ) + return message?.data.message.content +} + /** Render any listener-thrown value without letting coercion escape containment. */ function renderThrown(value: unknown): string { try { diff --git a/packages/subagent/subagent/src/run-settlement.ts b/packages/subagent/subagent/src/run-settlement.ts new file mode 100644 index 0000000000..92d0986bcd --- /dev/null +++ b/packages/subagent/subagent/src/run-settlement.ts @@ -0,0 +1,71 @@ +/** + * Settlement of one ONE-SHOT subagent run into a background-Task outcome. Only + * the one-shot background path uses Tasks; continuable children have no Task, + * no per-message result, and no Task cancellation. + * + * @module @deepseek-ai/dsh-subagent/run-settlement + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' +import type { SubagentResult, SubagentRun } from './types.ts' + +/** Flatten a child's final output blocks to the task's final text. */ +function finalText(blocks: ContentBlock[]): string { + return blocks + .filter((block): block is Extract => block.type === 'text') + .map(block => block.text) + .join('') +} + +/** + * Map a child result to the task outcome: completed carries final text, + * aborted is killed, and every other reason is failed without partial output. + * @param result - child terminal result. + * @returns outcome for the `ctx.tasks` registration. + */ +function runOutcome(result: SubagentResult): TaskOutcome { + switch (result.stopReason) { + case 'completed': + return { status: 'completed', output: finalText(result.output) } + case 'aborted': + return { status: 'killed' } + case 'error': + case 'max-tokens': + case 'refusal': + return { status: 'failed', detail: result.stopReason } + // Merge-extensible reasons remain failures with their raw detail. + default: + return { status: 'failed', detail: String(result.stopReason) } + } +} + +/** Render infrastructure failure detail without hiding a durability diagnosis. */ +function runFailureDetail(error: unknown): string { + return error instanceof HarnessError && error.code === 'DURABILITY_FAILED' + ? error.message + : String(error) +} + +/** + * Await the child result, dispose the run, then return its task outcome. Result + * and disposal failures become `failed`; when both fail, both details survive. + * @param run - live run to settle and release. + * @returns outcome after child resources are released. + */ +export async function settleRun(run: SubagentRun): Promise { + let outcome: TaskOutcome + try { + outcome = runOutcome(await run.result) + } catch (error: unknown) { + outcome = { status: 'failed', detail: runFailureDetail(error) } + } + try { + await run.dispose() + } catch (error: unknown) { + const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; ` + return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` } + } + return outcome +} diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index da75ae67cc..3ff7368b37 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -6,10 +6,9 @@ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { Branded } from '@deepseek-ai/dsh-brand' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' -import type { SubagentDescriptorData } from './descriptor.ts' /** Identifies one accepted subagent run across its lifecycle event pair. */ export type SubagentRunId = Branded<'SubagentRunId'> @@ -27,11 +26,12 @@ export function SubagentRunId(id: string): SubagentRunId { * Which START-TIME features a provider supports. Checked by the service before delegating to * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent - * degradation" rule). These static flags cover features needed before a run exists; runtime - * capabilities are optional methods whose presence is the capability — confirmed live steering - * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each - * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to - * `maxDepth`; the other names match. + * degradation" rule). These flags describe the ONE-SHOT + * {@link SubagentProvider.start} path, where the provider composes the child; + * continuable children are composed by the continuation manager itself and are + * gated by {@link SubagentProvider.prepareContinuable} instead. Each flag + * corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` + * to `maxDepth`; the other names match. */ export interface SubagentCapabilities { readonly outputSchema: boolean @@ -41,10 +41,10 @@ export interface SubagentCapabilities { } /** - * What a caller asks for when starting a subagent. The tool layer builds this - * from the model's `{ description, prompt }` plus its own config; the service - * validates {@link SubagentCapabilities} against the named provider and - * resolves a {@link SubagentProviderStartRequest} for dispatch. + * What a caller asks for when starting a ONE-SHOT subagent. The tool layer + * builds this from the model's `{ description, prompt }` plus its own config; + * the service validates {@link SubagentCapabilities} against the named provider + * before dispatching to {@link SubagentProvider.start}. */ export interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -96,63 +96,37 @@ export interface SubagentStartRequest { } /** - * Provider-facing start request after the service resolves optional - * continuation state. Ordinary callers use {@link SubagentStartRequest}; only - * the Task-backed continuation path can attach a stable child identity and - * durable descriptor. + * What the continuation manager asks a provider for while materializing one + * continuable child's FIRST activation. The manager has already reserved the + * durable child identity and owns every later operation, so this request + * carries only what distinguishes a fresh child from one seeded with parent + * history. */ -export interface SubagentProviderStartRequest extends SubagentStartRequest { - /** - * Continuable-child state resolved by `ctx.subagents` before provider dispatch. - * The provider MUST publish exactly `sessionId` as the child identity - * instead of allocating one internally, and MUST append the snapshotted, - * model-hidden `subagent/descriptor` before the initial prompt is admitted. - * Requires {@link SubagentProvider.resume} (the - * continuation capability); the service rejects the request otherwise. - */ - readonly continuation?: SubagentContinuation | undefined -} - -/** - * The resolved continuable-child identity and durable composition record the - * service attaches before provider dispatch. - */ -export interface SubagentContinuation { - /** Service-allocated stable child session id, published verbatim. */ +export interface ContinuableCreateRequest { + /** The reserved durable child session id, for provider diagnostics. */ readonly sessionId: SessionId - /** Snapshotted descriptor persisted in the child log for cold resume. */ - readonly descriptor: SubagentDescriptorData -} - -/** - * Provider-facing request for reconstructing a persisted continuable child. - * The continuation manager loads the child log, folds and authorizes its - * descriptor, then privately dispatches this resolved request to - * {@link SubagentProvider.resume}. The provider reconstructs the declared - * composition under the live parent's scope and drives one turn with `prompt`. - */ -export interface SubagentProviderResumeRequest { - /** The persisted child session id to resume. */ - readonly sessionId: SessionId - /** The follow-up message that starts the resumed activation's turn. */ - readonly prompt: ContentBlock[] - /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ - readonly source: MessageSource - /** - * The live parent agent — the direct parent recorded in the persisted child - * header. In-process backends reconstruct the child under this agent's - * currently loaded scope. - */ + /** The delegating parent agent whose history a seeding provider reads. */ readonly parent: Agent /** - * Activation-owned cancellation signal, created before descriptor lookup. - * Same pre/post-publication contract as {@link SubagentStartRequest.signal}: - * an abort before publication rejects after rollback quiescence, and an - * abort afterward cancels the published child turn. + * Caller cancellation, which owns preparation only until the manager accepts + * the initial prompt into the child's inbox. */ readonly signal: AbortSignal - /** The folded durable descriptor whose composition the provider reconstructs. */ - readonly descriptor: SubagentDescriptorData +} + +/** + * A provider's detached contribution to one continuable child's creation. This + * is DATA, never a capability: it carries no Agent, `AgentHandle`, prompt + * delivery, result, disposal, or resume operation, because the continuation + * manager owns the child's whole lifecycle after preparation. + */ +export interface ContinuableCreateSpec { + /** + * Completed-turn prefix of the parent's log to seed the child session with, + * or absent for a fresh child. Same durable contract as + * `CreateAgentOptions.seed`: contiguous from seq 0, lossless JSON, balanced. + */ + readonly seed?: readonly SessionEvent[] } /** @@ -196,9 +170,12 @@ export interface SubagentResult { } /** - * Child handle returned only after readiness. Consumers await {@link result} and must always - * {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime - * capability discovery; narrow their presence before calling. + * ONE-SHOT child handle returned only after readiness. Consumers await + * {@link result} and must always {@link dispose} to cancel remaining work and + * reach quiescence. A run is one disposable foreground delegation with one + * result; continuable conversations have no run — the continuation manager + * holds their `AgentHandle` directly and orders every turn through the child's + * own inbox. */ export interface SubagentRun { /** @@ -217,10 +194,8 @@ export interface SubagentRun { * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport * failure resolves with `stopReason: 'error'` so the consumer maps it to an - * `isError` tool result. For a continuable activation, a completed result - * also means the provider confirmed the activation's final state durable. - * Rejects on an infrastructure fault the seam cannot represent as a stop - * reason, including a failed required durability checkpoint. + * `isError` tool result. Rejects on an infrastructure fault the seam cannot + * represent as a stop reason. */ readonly result: Promise /** @@ -228,17 +203,6 @@ export interface SubagentRun { * Idempotent. */ dispose(): Promise - /** - * OPTIONAL (confirmed live-steering capability): submit additional content - * to the active child and fulfill only after a committed request snapshot - * admits it. Rejects when terminal policy, cancellation, disposal, or a lost - * settlement race prevents admission; it never falls through to a queued - * untracked turn or cold resume. A run represents one disposable activation, - * so resuming a settled child goes through {@link SubagentProvider.resume}. - * `source` is retained on the admitted steering message without changing its - * user role in model history. - */ - steer?(content: ContentBlock[], source: MessageSource): Promise } /** @@ -258,23 +222,27 @@ export interface SubagentProvider { */ readonly inheritsParentContext: boolean /** - * Establish a child and return its handle only after publication. The - * service has already validated that every requested start-time capability - * is supported, so an implementation may assume e.g. `request.maxDepth` is - * honorable when present. If setup fails or `request.signal` aborts before - * fulfillment, the provider owns and cleans all partial resources before this - * promise rejects. Ownership transfers to the caller only on fulfillment. + * Establish a ONE-SHOT child and return its handle only after publication. + * The service has already validated that every requested start-time + * capability is supported, so an implementation may assume e.g. + * `request.maxDepth` is honorable when present. If setup fails or + * `request.signal` aborts before fulfillment, the provider owns and cleans + * all partial resources before this promise rejects. Ownership transfers to + * the caller only on fulfillment. */ - start(request: SubagentProviderStartRequest): Promise + start(request: SubagentStartRequest): Promise /** - * OPTIONAL (continuation capability): reconstruct a persisted continuable - * child from its own transcript and declared descriptor, drive one - * follow-up turn, and return a fresh run. Method presence is the capability - * — the service rejects continuable starts and cold-resume dispatch on - * providers without it. Same publication contract as {@link start}: if - * reconstruction fails or `request.signal` aborts before fulfillment, the - * provider rolls its creation transaction back to quiescence before - * rejecting; after fulfillment the same signal cancels the published run. + * OPTIONAL (continuable-creation capability): contribute the detached + * creation inputs that distinguish this provider's continuable children — + * today only whether the child session is seeded with parent history. Method + * presence IS the capability: the service rejects continuable starts on + * providers without it, while a provider that has it may still serve + * ordinary one-shot delegations. + * + * This is the provider's ONLY participation in a continuable child. The + * continuation manager owns identity reservation, composition, Agent + * creation, prompt delivery, cold resume, ownership, and disposal, so a + * provider never sees the child's Agent, handle, turns, or teardown. */ - resume?(request: SubagentProviderResumeRequest): Promise + prepareContinuable?(request: ContinuableCreateRequest): Promise } diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index af85457262..1fbbc3d3fb 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -1,9 +1,9 @@ /** * The globally named `send_message` tool: a thin model-facing adapter over - * `ctx.subagents.followup()`. It performs no lifecycle routing of its - * own — steer-or-resume orchestration belongs to the subagent service — and it - * lives apart from the provider-bound `@deepseek-ai/dsh-tool-subagent` - * instances so multiple delegation tools share one control tool. + * `ctx.subagents.followup()`. It performs no lifecycle routing of its own — + * residency and cold resume belong to the subagent service — and it lives apart + * from the provider-bound `@deepseek-ai/dsh-tool-subagent` instances so multiple + * delegation tools share one control tool. * @module @deepseek-ai/dsh-tool-subagent-control */ @@ -24,10 +24,10 @@ export function apply(ctx: Context): void { ctx.tools.register(defineTool({ name: 'send_message', description: - 'Send a follow-up message to a background subagent by its subagent id. If it is still working, the ' - + 'message joins its current task; if it has finished, this starts a new task that continues the same ' - + 'subagent conversation. Either way the response arrives through the returned task id — collect it ' - + 'with `task_output`. A failure means the message was NOT delivered.', + '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 — read its ' + + 'transcript by its id to see what it did. A failure means the message was NOT delivered.', parameters: { subagent_id: { type: 'string', @@ -45,30 +45,23 @@ export function apply(ctx: Context): void { type: 'object', additionalProperties: false, properties: { - route: { - type: 'string', - required: true, - enum: ['steered', 'started'], - }, - taskId: { type: 'string', required: true }, + messageId: { type: 'string', required: true }, }, }, - render: (args, value) => [{ + render: (args, _value) => [{ type: 'text', - text: value.route === 'steered' - ? `message delivered to running task ${value.taskId}` - : `message started task ${value.taskId} continuing subagent ${args.subagent_id}`, + text: `message queued as the next turn for subagent ${args.subagent_id}`, }], }, async execute(args, exec) { const parent = exec.agent if (!parent) { - // Non-agent callers have no session to authorize Task access with. + // Parent authority requires an exact live calling agent. throw new Error('send_message requires a calling agent (exec.agent was undefined)') } const message: ContentBlock[] = [{ type: 'text', text: args.message }] - const result = await ctx.subagents.followup( - parent, + const messageId = await ctx.subagents.followup( + { kind: 'parent', agent: parent }, SessionId(args.subagent_id), message, { @@ -76,7 +69,7 @@ export function apply(ctx: Context): void { signal: exec.signal, }, ) - return result + return { messageId } }, })) } diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 6033bea875..62fef8c055 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -36,9 +36,9 @@ export interface Config { */ enableRunInBackground?: boolean /** - * Background execution policy (default `one-shot`). `continuable` requires - * a provider with persisted resume support and returns both child and Task - * ids; follow-up adapters remain independently optional. + * Background execution policy (default `one-shot`). `continuable` requires a + * provider with the `prepareContinuable` capability and returns the durable + * child id; follow-up adapters remain independently optional. */ backgroundMode?: 'one-shot' | 'continuable' /** @@ -197,7 +197,7 @@ export function apply(ctx: Context, config: Config): void { const wording = providerWording(provider.inheritsParentContext) const backgroundEnabled = config.enableRunInBackground !== false const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable' - if (continuable && provider.resume === undefined) { + if (continuable && provider.prepareContinuable === undefined) { throw new Error( `tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``, ) @@ -206,9 +206,9 @@ export function apply(ctx: Context, config: Config): void { name: config.toolName ?? 'subagent', description: wording.description + (backgroundEnabled ? continuable - ? ' Set `run_in_background: true` to start a continuable background subagent: you receive its' - + ' stable subagent id and current task id; collect the result with `task_output` and stop it with' - + ' `task_kill`.' + ? ' 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 to you, so read' + + ' its transcript by that id, or send it more work with `send_message`.' : ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' : ''), parameters: { @@ -226,8 +226,8 @@ export function apply(ctx: Context, config: Config): void { run_in_background: { type: 'boolean' as const, description: continuable - ? 'Run as a continuable background subagent and return its subagent and task ids; ' - + 'collect with task_output or stop with task_kill.' + ? 'Run as a background subagent that keeps its conversation and return its subagent id; ' + + 'send it more work with send_message.' : 'Run as a background task and return its id; collect with task_output or stop with task_kill.', }, } : {}, @@ -241,7 +241,14 @@ export function apply(ctx: Context, config: Config): void { properties: { kind: { type: 'string', required: true, const: 'background' }, taskId: { type: 'string', required: true }, - subagentId: { type: 'string' }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'continuable' }, + subagentId: { type: 'string', required: true }, }, }, { @@ -258,10 +265,10 @@ export function apply(ctx: Context, config: Config): void { render: (_args, value) => [{ type: 'text', text: value.kind === 'background' - ? value.subagentId === undefined - ? `started background subagent task ${value.taskId}` - : `started subagent ${value.subagentId} as task ${value.taskId}` - : outputValueText(value.output), + ? `started background subagent task ${value.taskId}` + : value.kind === 'continuable' + ? `started subagent ${value.subagentId}` + : outputValueText(value.output), }], }, async execute(args, exec) { @@ -288,16 +295,14 @@ export function apply(ctx: Context, config: Config): void { throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)') } if (continuable) { - const started = ctx.subagents.startContinuable({ + // Resolves at inbox acceptance: the child owns its own turns from + // there, so this call neither waits for nor collects a result. + const started = await ctx.subagents.startContinuable({ provider: config.provider, - label: args.description, request, + signal: exec.signal, }) - return { - kind: 'background' as const, - taskId: started.taskId, - subagentId: started.childId, - } + return { kind: 'continuable' as const, subagentId: started.childId } } const tasks = ctx.get('tasks') if (tasks === undefined) { From 1c30548068ad21384c65c758f05f51efef6cb9da Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:31:02 +0800 Subject: [PATCH 050/114] test(subagent): rewrite continuation spec for activation lifecycle Covers the inbox-acceptance return boundary, pre-acceptance rollback, caller-signal ownership on both sides of acceptance, residency-only routing, single-inbox FIFO ordering across parent and user origins, waiting wakeup with a retained handle, cold resume without the historical parent, ownership registration and release, child-first disposal, send-versus-dispose races, durability failure without an ownership leak, per-epoch lifecycle pairing, and the absence of cancellation, steering, and report surfaces. --- .../subagent/tests/continuation.spec.ts | 1261 +++++++---------- 1 file changed, 474 insertions(+), 787 deletions(-) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 24392d847e..8335d665e1 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -11,17 +11,14 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' -import { TaskId } from '@deepseek-ai/dsh-tasks' -import LocalTaskService from '@deepseek-ai/dsh-tasks-local' -import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm' -import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService, { - settleRun, SubagentError, SUBAGENT_DESCRIPTOR_VERSION, } from '../src/index.ts' +import type { SubagentAuthority, SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -56,13 +53,14 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -/** Boot the full continuable stack: loop, persistence, providers, tasks, and subagents. */ +/** Boot the full continuable stack: loop, persistence, providers, and subagents. */ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) let disposePersistence: (() => Promise) | undefined + let root: string | undefined if (options.persistence !== false) { - const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) + root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) roots.push(root) const persistenceFiber = await ctx.plugin(JsonlSessionPersistence, { root }) disposePersistence = () => persistenceFiber.dispose() @@ -71,83 +69,88 @@ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) - await ctx.plugin(LocalTaskService) - await ctx.plugin(ToolTasks, {}) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - return { ctx, parent, disposePersistence } + return { ctx, parent, disposePersistence, root } } async function setup(script: Script, options: { persistence?: boolean } = {}) { const adapter = new MockAdapter(script) - const { ctx, parent } = await setupWith(adapter, options) - return { ctx, parent, adapter } + const booted = await setupWith(adapter, options) + return { ...booted, adapter } } -function startSpec(parent: Agent, provider = 'spawn') { +const testSignal = new AbortController().signal + +function startSpec(parent: Agent, provider = 'spawn', signal: AbortSignal = testSignal) { return { provider, - label: 'delegated work', request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent }, + signal, } } -async function waitTerminal(ctx: Context, taskId: TaskId, parent: Agent) { - return ctx.tasks.wait(taskId, 5_000, parent) -} - -async function waitPublishedRun(ctx: Context, childId: SessionId): Promise { - const continuations = ctx.subagents as unknown as { - continuations: { activations: Map } - } - await new Promise((resolve) => { - const timer = setInterval(() => { - if (continuations.continuations.activations.get(childId)?.run !== undefined) { - clearInterval(timer) - resolve() - } - }, 5) - }) -} - function message(text: string) { return [{ type: 'text' as const, text }] } -const coordinatorSource = { - kind: 'coordinator', - senderSessionId: SessionId('parent'), -} as const -const testSendSignal = new AbortController().signal +function hasUserText(events: readonly SessionEvent[], text: string): boolean { + return events.some(event => event.type === 'user/message' + && event.data.content.some(block => block.type === 'text' && block.text === text)) +} + +/** Every user-role message text in log order, for FIFO assertions. */ +function userTexts(events: readonly SessionEvent[]): string[] { + return events.flatMap(event => event.type === 'user/message' + ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) + : []) +} function followup( ctx: Context, - parent: Agent, + authority: SubagentAuthority, childId: SessionId, content: ReturnType, - signal: AbortSignal = testSendSignal, + signal: AbortSignal = testSignal, ) { - return ctx.subagents.followup(parent, childId, content, { + return ctx.subagents.followup(authority, childId, content, { source: { kind: 'user' }, signal, }) } -describe('SubagentService.startContinuable', () => { - it('returns both identities immediately; the Task settles with the child result after disposal', async () => { - const { ctx, parent } = await setup([textResponse('first answer')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - expect(started.childId).toMatch(/[0-9a-f-]{36}/) - expect(started.taskId).toBe('subagent-1') +/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */ +async function waitNoActivation(ctx: Context, childId: SessionId): Promise { + await vi.waitFor(() => { + expect(ctx.subagents.activationState(childId)).toBeUndefined() + expect(ctx.agents.get(childId)).toBeUndefined() + }, { timeout: 5_000 }) +} - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('completed') - expect(ctx.tasks.read(started.taskId, parent).text).toBe('first answer') - // Disposal ordering: the terminal Task leaves no live child Agent. - expect(ctx.agents.get(started.childId)).toBeUndefined() +describe('SubagentService.startContinuable', () => { + it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => { + const { ctx, parent, adapter } = await setup([textResponse('first answer')]) + const enqueued: { id: MessageId; loggedYet: boolean }[] = [] + ctx.on('agent/inbox/enqueue', (agent, accepted) => { + // Acceptance is the boundary `startContinuable` resolves at, so observe + // the log state exactly there rather than after later microtasks. + enqueued.push({ id: accepted.message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + + expect(started.childId).toMatch(/[0-9a-f-]{36}/) + // The returned id is exactly the accepted inbox message's id, and nothing + // was logged or requested to earn it. + expect(enqueued).toEqual([{ id: started.messageId, loggedYet: false }]) + expect(adapter.requests).toEqual([]) + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'child task')).toBe(true) }) - it('fails a continuable Task before dispatch when its provider has no resume capability', async () => { + it('rejects without ids when the provider has no prepareContinuable capability', async () => { const { ctx, parent } = await setup([]) const start = vi.fn(async () => { throw new Error('must not dispatch') }) ctx.subagents.registerProvider({ @@ -157,48 +160,30 @@ describe('SubagentService.startContinuable', () => { start, }) - const started = ctx.subagents.startContinuable(startSpec(parent, 'one-shot')) - const snapshot = await waitTerminal(ctx, started.taskId, parent) - - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('does not support continuable children') + await expect(ctx.subagents.startContinuable(startSpec(parent, 'one-shot'))) + .rejects.toThrow(/does not support continuable children/) expect(start).not.toHaveBeenCalled() + // No child Agent and no session were created. + expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) }) - it('fails the Task when persistence detaches before the activation completes', async () => { - const releaseResponse = Promise.withResolvers() - const adapter = new GatedAdapter([ - { chunks: textResponse('unconfirmed answer'), gate: releaseResponse.promise }, - ]) - const { ctx, parent, disposePersistence } = await setupWith(adapter) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - - await disposePersistence!() - releaseResponse.resolve(undefined) - - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('durability checkpoint failed') - expect(snapshot.detail).toContain('required durability checkpoint has no registered listener') - expect(ctx.agents.get(started.childId)).toBeUndefined() + it('rejects synchronously when persistence is not configured', async () => { + const { ctx, parent } = await setup([textResponse('unused')], { persistence: false }) + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toThrow(/require session persistence/) }) - it('publishes the service-allocated child id and appends the pre-turn descriptor', async () => { + it('publishes the reserved child id and appends the pre-turn descriptor', async () => { const { ctx, parent } = await setup([textResponse('answer')]) - const seen: SessionEvent[] = [] - ctx.on('session/event', (session, event) => { - if (session.id !== SessionId('parent')) seen.push(event) - }) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) - const descriptorIndex = seen.findIndex(event => event.type === 'subagent/descriptor') - const turnStartIndex = seen.findIndex(event => event.type === 'turn/start') - const firstAssistant = seen.findIndex(event => event.type === 'assistant/message') + const loaded = await ctx.sessionPersistence.load(started.childId) + const descriptorIndex = loaded.events.findIndex(event => event.type === 'subagent/descriptor') + const turnStartIndex = loaded.events.findIndex(event => event.type === 'turn/start') + expect(descriptorIndex).toBeGreaterThanOrEqual(0) expect(descriptorIndex).toBeLessThan(turnStartIndex) - expect(descriptorIndex).toBeLessThan(firstAssistant) - const descriptor = seen[descriptorIndex] as SessionEvent<'subagent/descriptor'> + const descriptor = loaded.events[descriptorIndex] as SessionEvent<'subagent/descriptor'> expect(descriptor.data).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', @@ -207,732 +192,434 @@ describe('SubagentService.startContinuable', () => { }) // Model-hidden: the descriptor never carries surface metadata. expect('surfaceOp' in descriptor).toBe(false) - - // The durable log kept the exact service-allocated id. - const loaded = await ctx.sessionPersistence.load(started.childId) expect(loaded.meta.id).toBe(started.childId) expect(loaded.meta.parentSession).toBe(SessionId('parent')) - expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) }) - it.each(['block', 'throw'] as const)( - 'persists the descriptor before initial prompt admission can $0', - async (outcome) => { - const { ctx, parent, adapter } = await setup([]) - ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => { - if (subject === parent) return next() - if (outcome === 'block') return { kind: 'block', reason: 'blocked by policy' } - throw new Error('prompt admission failed') - }) - - const started = ctx.subagents.startContinuable(startSpec(parent)) - const snapshot = await waitTerminal(ctx, started.taskId, parent) - - expect(snapshot.status).toBe('failed') - expect(adapter.requests).toEqual([]) - const loaded = await ctx.sessionPersistence.load(started.childId) - const descriptorIndexes = loaded.events.flatMap((event, index) => - event.type === 'subagent/descriptor' ? [index] : []) - expect(descriptorIndexes).toHaveLength(1) - expect(loaded.events.some(event => event.type === 'turn/start')).toBe(false) - }, - ) - - it('rejects synchronously with no Task when persistence is not configured', async () => { - const { ctx, parent } = await setup([textResponse('unused')], { persistence: false }) - expect(() => ctx.subagents.startContinuable(startSpec(parent))) - .toThrow(/require session persistence/) - expect(ctx.tasks.list(parent)).toEqual([]) - }) - - it('rolls back the activation when Task preflight throws', async () => { + it('rolls the child back completely when the caller signal aborts before acceptance', async () => { const { ctx, parent } = await setup([textResponse('unused')]) - const realStart = ctx.tasks.start.bind(ctx.tasks) - ctx.tasks.start = () => { throw new Error('task preflight failed') } - try { - expect(() => ctx.subagents.startContinuable(startSpec(parent))) - .toThrow('task preflight failed') - } finally { - ctx.tasks.start = realStart - } - const continuations = ctx.subagents as unknown as { - continuations: { activations: Map } - } - expect(continuations.continuations.activations.size).toBe(0) - }) - - it('rejects a non-JSON descriptor input synchronously with no Task', async () => { - const { ctx, parent } = await setup([textResponse('unused')]) - const spec = startSpec(parent) - expect(() => ctx.subagents.startContinuable({ - ...spec, - // A symbol survives the static ToolRestriction type only through this - // cast — exactly the durable-boundary input the snapshot rejects. - request: { ...spec.request, toolFilter: { deny: [Symbol('boom') as unknown as string] } }, - })).toThrow(/not losslessly JSON-serializable/) - expect(ctx.tasks.list(parent)).toEqual([]) - }) - - it('settles the Task as failed when provider startup fails after the ids were returned', async () => { - const { ctx, parent } = await setup([textResponse('unused')]) - const spec = { - provider: 'spawn', - label: 'broken delegation', - request: { - prompt: [{ type: 'text' as const, text: 'child task' }], - parent, - // The spawn provider enforces depth: parent depth 0 → child depth 1 > 0. - maxDepth: 0, - }, - } - const started = ctx.subagents.startContinuable(spec) - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('maxDepth') - // The unmaterialized child id is reported unavailable on later use. - const followUp = await followup(ctx, parent, started.childId, message('hello?')) - expect(followUp.route).toBe('started') - const failed = await waitTerminal(ctx, followUp.taskId, parent) - expect(failed.status).toBe('failed') - expect(failed.detail).toContain('unavailable') - }) - - it('task_kill during the run aborts, disposes, and settles killed after quiescence', async () => { - const { ctx, parent } = await setup(['hang']) - const started = ctx.subagents.startContinuable(startSpec(parent)) - // Let the child publish and begin its turn. - await new Promise(resolve => setTimeout(resolve, 30)) - expect(ctx.agents.get(started.childId)).toBeDefined() - expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested') - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('killed') - expect(ctx.agents.get(started.childId)).toBeUndefined() - }) - - it('task_kill during the final durability checkpoint settles killed', async () => { - const { ctx, parent } = await setup([textResponse('driver answer')]) - const checkpointStarted = Promise.withResolvers() - const releaseCheckpoint = Promise.withResolvers() - let flushes = 0 - ctx.on('session/flush', async (session) => { - if (session.header.parentSession === undefined) return - flushes++ - if (flushes !== 2) return - checkpointStarted.resolve(undefined) - await releaseCheckpoint.promise + const controller = new AbortController() + // Abort inside the child's creation window: setup runs before publication. + ctx.on('agent/created', (child) => { + if (child !== parent) controller.abort('caller gave up') }) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await checkpointStarted.promise - expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested') - releaseCheckpoint.resolve(undefined) + await expect(ctx.subagents.startContinuable(startSpec(parent, 'spawn', controller.signal))) + .rejects.toThrow() + // No Activation, no live child Agent, and no parent ownership remains. + await vi.waitFor(() => { + expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) + }) + }) - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('killed') - expect(ctx.agents.get(started.childId)).toBeUndefined() + it('rejects a continuable child that would exceed the configured depth cap', async () => { + const { ctx, parent } = await setup([]) + await expect(ctx.subagents.startContinuable({ + ...startSpec(parent), + request: { prompt: message('deep'), parent, maxDepth: 0 }, + })).rejects.toThrow(/exceeds maxDepth 0/) + expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) + }) + + it('records the declared persona in the descriptor and reapplies it on cold resume', async () => { + const { ctx, parent } = await setup([textResponse('scoped'), textResponse('resumed')]) + const started = await ctx.subagents.startContinuable({ + ...startSpec(parent), + request: { + prompt: message('scoped work'), + parent, + persona: 'You are scoped.', + }, + }) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + const descriptor = loaded.events.find(event => event.type === 'subagent/descriptor') + expect(descriptor?.data).toMatchObject({ persona: 'You are scoped.' }) + + // Cold resume reconstructs the declared composition from that descriptor. + await followup(ctx, { kind: 'user' }, started.childId, message('resume it')) + await waitNoActivation(ctx, started.childId) + const resumed = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(resumed.events, 'resume it')).toBe(true) }) }) -describe('SubagentService.followup', () => { - it('fails a cold-resume Task when the provider loses its resume capability', async () => { - const { ctx, parent } = await setup([textResponse('first answer')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) - - const provider = ctx.subagents.getProvider('spawn')! - Object.defineProperty(provider, 'resume', { value: undefined, configurable: true }) - - const next = await followup(ctx, parent, started.childId, message('continue')) - const snapshot = await waitTerminal(ctx, next.taskId, parent) - - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('does not support resuming persisted children') - }) - - it('omits undeclared model selectors and rejects a provider without live delivery', async () => { - const { ctx } = await setup([]) - const result = Promise.withResolvers<{ - output: { type: 'text'; text: string }[] - stopReason: 'completed' - }>() - let descriptor: SessionEvent<'subagent/descriptor'>['data'] | undefined - ctx.subagents.registerProvider({ - name: 'no-steer', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start: async (request) => { - descriptor = request.continuation?.descriptor - return { - id: request.continuation!.sessionId, - localAgent: undefined, - result: result.promise, - async dispose() {}, - } - }, - resume: async () => { throw new Error('not used') }, - }) - const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}) - const started = ctx.subagents.startContinuable(startSpec(parent, 'no-steer')) - await waitPublishedRun(ctx, started.childId) - - expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) - await expect(followup(ctx, parent, started.childId, message('join'))) - .rejects.toThrow(/provider does not accept live delivery/) - - let terminalDeliveryError: unknown - let terminalDelivery: Promise | undefined - ctx.tasks.onTaskDone((snapshot) => { - if (snapshot.id !== started.taskId) return - terminalDelivery = followup(ctx, parent, started.childId, message('after terminal')).then( - () => undefined, - (error: unknown) => { - terminalDeliveryError = error - }, - ) - }) - result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) - await waitTerminal(ctx, started.taskId, parent) - await terminalDelivery - expect(String(terminalDeliveryError)).toContain('is completed') - }) - - it('rejects a registry agent different from the associated run agent', async () => { - const { ctx, parent } = await setup([]) - const result = Promise.withResolvers<{ - output: { type: 'text'; text: string }[] - stopReason: 'completed' - }>() - ctx.subagents.registerProvider({ - name: 'mismatched-local', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start: async (request) => { - const childId = request.continuation!.sessionId - const handle = await ctx.agents.create({ - sessionId: childId, - meta: { parentSession: request.parent.id }, - agentOptions: { provider: 'mock', model: 'mock' }, - }) - return { - id: childId, - localAgent: {} as Agent, - result: result.promise, - dispose: () => handle.dispose(), - } - }, - resume: async () => { throw new Error('not used') }, - }) - const started = ctx.subagents.startContinuable(startSpec(parent, 'mismatched-local')) - await waitPublishedRun(ctx, started.childId) - - await expect(followup(ctx, parent, started.childId, message('join'))) - .rejects.toThrow(/registry agent is not the associated activation's agent/) - result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) - await waitTerminal(ctx, started.taskId, parent) - }) - - it('steers a running activation into the existing Task without creating a second Task', async () => { - // Hold the child's first model call open so the child is observably - // running when the message arrives; the steered content then drives a - // second step in the SAME turn. - let releaseFirst!: () => void - const gate = new Promise((resolve) => { releaseFirst = resolve }) +describe('SubagentService.followup residency routing', () => { + it('enqueues in the same Activation while it is running, preserving one inbox FIFO', async () => { + const releaseFirst = Promise.withResolvers() const adapter = new GatedAdapter([ - { chunks: textResponse('first step answer'), gate }, - { chunks: textResponse('steered turn answer') }, + { chunks: textResponse('first'), gate: releaseFirst.promise }, + { chunks: textResponse('second') }, + { chunks: textResponse('third') }, ]) const { ctx, parent } = await setupWith(adapter) - - const started = ctx.subagents.startContinuable(startSpec(parent)) - // Wait until the first immutable request has crossed the adapter boundary. - await new Promise((resolve) => { - const timer = setInterval(() => { - if (adapter.requests.length === 1) { - clearInterval(timer) - resolve() - } - }, 5) - }) - - const delivery = ctx.subagents.followup( - parent, - started.childId, - message('also consider Y'), - { source: coordinatorSource, signal: testSendSignal }, - ) - releaseFirst() - const delivered = await delivery - expect(delivered).toEqual({ route: 'steered', taskId: started.taskId }) - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('completed') - // Exactly one Task exists: steering created none. - expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId]) - // The steered content joined the SAME child turn and drove another step. - const output = ctx.tasks.read(started.taskId, parent) - expect(output.text).toBe('steered turn answer') - const loaded = await ctx.sessionPersistence.load(started.childId) - const steering = loaded.events.find( - (event): event is SessionEvent<'steering/message'> => event.type === 'steering/message', - ) - expect(steering?.data.message.source).toEqual(coordinatorSource) - }) - - it('cancels the active Task without enqueueing when live delivery is already aborted', async () => { - const { ctx, parent, adapter } = await setup(['hang']) - const started = ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId) + expect(ctx.subagents.activationState(started.childId)).toBe('running') + + // Both origins queue behind the open turn, in call order. + const parentMessage = await followup(ctx, { kind: 'parent', agent: parent }, started.childId, message('from parent')) + const userMessage = await followup(ctx, { kind: 'user' }, started.childId, message('from user')) + expect(parentMessage).not.toBe(userMessage) + // Still the same Activation: no second child Agent was created. + expect(ctx.agents.get(started.childId)).toBe(child) + + releaseFirst.resolve() + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(userTexts(loaded.events)).toEqual(['child task', 'from parent', 'from user']) + }) + + it('cold-resumes a settled child into a new Activation', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + const messageId = await followup(ctx, { kind: 'user' }, started.childId, message('continue please')) + expect(messageId).toBeTypeOf('string') + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(userTexts(loaded.events)).toEqual(['child task', 'continue please']) + // One descriptor only: cold resume never re-seeds it. + expect(loaded.events.filter(event => event.type === 'subagent/descriptor')).toHaveLength(1) + }) + + it('wakes a waiting Activation instead of cold-resuming it', async () => { + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + // The child delegates, then finishes its own turn while the grandchild runs. + { chunks: textResponse('child done') }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + { chunks: textResponse('woken') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + // The child starts its own continuable grandchild, then goes quiescent. + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + await vi.waitFor(() => { expect(adapter.requests.length).toBeGreaterThanOrEqual(2) }) + await vi.waitFor(() => { + expect(ctx.subagents.activationState(started.childId)).toBe('waiting') + }, { timeout: 5_000 }) + // Waiting retains the handle: the same Agent is still live. + expect(ctx.agents.get(started.childId)).toBe(child) + + await followup(ctx, { kind: 'user' }, started.childId, message('while waiting')) + // Woken back to running on the SAME Activation. + expect(ctx.agents.get(started.childId)).toBe(child) + + releaseGrandchild.resolve() + await waitNoActivation(ctx, grandchild.childId) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(userTexts(loaded.events)).toEqual(['child task', 'while waiting']) + }) + + it('rejects a parent that is not the durable direct parent', async () => { + const { ctx, parent } = await setup([textResponse('first')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) + + await expect(followup(ctx, { kind: 'parent', agent: stranger }, started.childId, message('mine now'))) + .rejects.toThrow(/belongs to another parent session/) + }) + + it('lets user authority cold-resume a child without loading its historical parent', async () => { + const { ctx, parent, root } = await setup([textResponse('first')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + await ctx.sessionPersistence.load(started.childId) + + // A fresh runtime over the same store has no parent Agent at all. + const fresh = new Context() + await mountAgentLoopTestDependencies(fresh) + await fresh.plugin(JsonlSessionPersistence, { root: root! }) + await fresh.plugin(AgentLoop, { agents: [] }) + await fresh.plugin(SubagentService) + await fresh.plugin(SubagentSpawn, { providerName: 'spawn' }) + fresh.llm.registerAdapter(['mock'], new MockAdapter([textResponse('resumed cold')])) + expect(fresh.agents.get(SessionId('parent'))).toBeUndefined() + + await followup(fresh, { kind: 'user' }, started.childId, message('user continues')) + await waitNoActivation(fresh, started.childId) + + const loaded = await fresh.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'user continues')).toBe(true) + // The historical parent was never reconstructed. + expect(fresh.agents.get(SessionId('parent'))).toBeUndefined() + }) + + it('reports an unresumable child whose persisted log has no supported descriptor', async () => { + const { ctx, parent } = await setup([textResponse('one shot')]) + // A ONE-SHOT child persists a log but never seeds a descriptor. + const run = await ctx.subagents.start('spawn', { + prompt: message('one-shot work'), + parent, + signal: testSignal, + }) + await run.result + await ctx.sessions.flush(run.localAgent!.session) + const oneShotId = run.id + await run.dispose() + + await expect(followup(ctx, { kind: 'user' }, oneShotId, message('continue'))) + .rejects.toThrow(/no supported continuation state/) + }) + + it('reports an unknown child id as unavailable', async () => { + const { ctx } = await setup([]) + await expect(followup(ctx, { kind: 'user' }, SessionId('missing'), message('hello'))) + .rejects.toMatchObject({ code: 'NOT_RESUMABLE' }) + }) + + it('cold-resumes after losing a race with final disposal', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('after the race')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + // Send exactly while the Activation is settling: one side wins the cutoff, + // and a delivery that loses waits for release and cold-resumes. + await child.whenIdle() + const delivery = followup(ctx, { kind: 'user' }, started.childId, message('raced')) + + await expect(delivery).resolves.toBeTypeOf('string') + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'raced')).toBe(true) + }) +}) + +describe('continuable child ownership', () => { + it('keeps a parent Activation waiting until its child completes disposal', async () => { + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child done') }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + + await vi.waitFor(() => { + expect(ctx.subagents.activationState(started.childId)).toBe('waiting') + }, { timeout: 5_000 }) + // Child-first: the parent handle is retained while the grandchild is live. + expect(ctx.agents.get(started.childId)).toBe(child) + expect(ctx.agents.get(grandchild.childId)).toBeDefined() + + releaseGrandchild.resolve() + await waitNoActivation(ctx, grandchild.childId) + await waitNoActivation(ctx, started.childId) + }) + + it('does not add a top-level parent to the waiting graph', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + // The top-level parent has no Activation of its own. + expect(ctx.subagents.activationState(parent.id)).toBeUndefined() + expect(ctx.agents.get(parent.id)).toBe(parent) + }) +}) + +describe('continuable durability and teardown', () => { + it('reports DURABILITY_FAILED without leaking a waiting Activation', async () => { + const releaseResponse = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('unconfirmed answer'), gate: releaseResponse.promise }, + ]) + const { ctx, parent, disposePersistence } = await setupWith(adapter) + const warnings: string[] = [] + ctx.logger.warn = (message: string) => { warnings.push(message) } + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + // Remove every durability listener, so the final checkpoint cannot confirm. + await disposePersistence!() + releaseResponse.resolve() + + // The handle is still disposed and ownership released, so nothing is pinned. + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { + expect(warnings.some(warning => warning.includes('durability'))).toBe(true) + }) + }) + + it('disposes every live Activation forest child-first on manager teardown', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child done') }, + { chunks: textResponse('grandchild'), gate: hold.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() }) + + const disposals: SessionId[] = [] + ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) }) + const drained = ctx.subagents.drainContinuable() + // Let the held model call observe its cancellation so quiescence can settle. + hold.resolve() + await drained + + // Child-first: the grandchild's disposal precedes its parent's. + expect(disposals.indexOf(grandchild.childId)).toBeGreaterThanOrEqual(0) + expect(disposals.indexOf(grandchild.childId)) + .toBeLessThan(disposals.indexOf(started.childId)) + // Durable sessions survive process-local teardown. + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.meta.id).toBe(started.childId) + }) + + it('rejects new materialization and delivery once draining begins', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + await ctx.subagents.drainContinuable() + + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toMatchObject({ code: 'DRAINING' }) + await expect(followup(ctx, { kind: 'user' }, started.childId, message('too late'))) + .rejects.toMatchObject({ code: 'DRAINING' }) + }) + + it('has no automatic replay for an accepted but unlogged message', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('first'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + // Accepted into the inbox, but this queued turn never opens. + await followup(ctx, { kind: 'user' }, started.childId, message('never logged')) + + const drained = ctx.subagents.drainContinuable() + hold.resolve() + await drained + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + // Only what actually reached the log is reconstructable. + expect(hasUserText(loaded.events, 'never logged')).toBe(false) + }) +}) + +describe('continuable lifecycle observation', () => { + it('emits one paired start/end per residency epoch', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) + const starts: SubagentRunInfo[] = [] + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/start', info => { starts.push(info) }) + ctx.on('subagent/end', info => { ends.push(info) }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + + // A cold resume is a NEW epoch with its own pair. + await followup(ctx, { kind: 'user' }, started.childId, message('again')) + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { expect(ends).toHaveLength(2) }) + + expect(starts).toHaveLength(2) + expect(starts.map(info => info.id)).toEqual([started.childId, started.childId]) + expect(starts.map(info => info.provider)).toEqual(['spawn', 'spawn']) + // Each end pairs its own start's runId. + expect(ends.map(info => info.runId)).toEqual(starts.map(info => info.runId)) + }) +}) + +describe('continuable public surface', () => { + it('exposes no cancellation, steering, or report operation', async () => { + const { ctx } = await setup([]) + const subagents: Record = ctx.subagents as unknown as Record + for (const absent of ['cancel', 'kill', 'steer', 'steerContinuable', 'report', 'resume']) { + expect(subagents[absent]).toBeUndefined() + } + // No steering tool and no report tool are registered by this seam. + const names = ctx.tools.schemas().map(schema => schema.name) + expect(names).not.toContain('report') + expect(names).not.toContain('steer_subagent') + }) + + it('keeps one-shot runs free of a steering capability', async () => { + const { ctx, parent } = await setup([textResponse('one shot')]) + const run = await ctx.subagents.start('spawn', { + prompt: message('one-shot work'), + parent, + signal: testSignal, + }) + expect('steer' in run).toBe(false) + await run.result + await run.dispose() + }) + + it('reports a caller-signal abort before acceptance without delivering', async () => { + const { ctx, parent } = await setup([textResponse('first')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + const controller = new AbortController() - controller.abort('caller already cancelled') + controller.abort('caller gave up') + await expect(followup(ctx, { kind: 'user' }, started.childId, message('aborted'), controller.signal)) + .rejects.toThrow() - await expect(followup( - ctx, - parent, - started.childId, - message('must not enqueue'), - controller.signal, - )).rejects.toMatchObject({ code: 'CANCELLED' }) - expect(ctx.agents.get(started.childId)).toBeUndefined() - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('killed') const loaded = await ctx.sessionPersistence.load(started.childId) - expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) + expect(hasUserText(loaded.events, 'aborted')).toBe(false) }) - it('rejects before acknowledgement when terminal policy prevents steering admission', async () => { - const { ctx, parent, adapter } = await setup([ - toolCallResponse('c1', 'structured_output', { answer: 7 }), + it('does not cancel an accepted turn when the caller signal aborts afterwards', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('first'), gate: releaseFirst.promise }, + { chunks: textResponse('second') }, ]) - const startedTool = Promise.withResolvers() - const releaseTool = Promise.withResolvers() - ctx.on('tools/pre-execute', async (exec, next) => { - if (exec.name === 'structured_output') { - startedTool.resolve(undefined) - await releaseTool.promise - } - return next() - }) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - const base = startSpec(parent) - const started = ctx.subagents.startContinuable({ - ...base, - request: { - ...base.request, - outputSchema: { - type: 'object', - properties: { answer: { type: 'number' } }, - required: ['answer'], - }, - }, - }) - await startedTool.promise + const controller = new AbortController() + await followup(ctx, { kind: 'user' }, started.childId, message('survives'), controller.signal) + // After acceptance the manager owns the Activation independently. + controller.abort('caller gave up') - const delivery = ctx.subagents.followup( - parent, - started.childId, - message('follow-up that terminal policy rejects'), - { source: coordinatorSource, signal: testSendSignal }, - ) - releaseTool.resolve(undefined) - await expect(delivery).rejects.toThrow(/message was not delivered/) - - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('completed') - expect(adapter.requests).toHaveLength(1) + releaseFirst.resolve() + await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) + expect(hasUserText(loaded.events, 'survives')).toBe(true) }) +}) - it('cold-resumes a settled child into a fresh Task and reports `started`', async () => { - const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) - expect(ctx.agents.get(started.childId)).toBeUndefined() - - const followUp = await ctx.subagents.followup( - parent, - started.childId, - message('and then?'), - { source: coordinatorSource, signal: testSendSignal }, - ) - expect(followUp.route).toBe('started') - expect(followUp.taskId).not.toBe(started.taskId) - const snapshot = await waitTerminal(ctx, followUp.taskId, parent) - expect(snapshot.status).toBe('completed') - expect(ctx.tasks.read(followUp.taskId, parent).text).toBe('second answer') - // Fresh activation disposed again: durable child, no live Agent. - expect(ctx.agents.get(started.childId)).toBeUndefined() - - // The durable transcript accumulated BOTH activations' turns. - const loaded = await ctx.sessionPersistence.load(started.childId) - const userMessages = loaded.events.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message') - expect(userMessages.map(event => (event.data.content[0] as { text: string }).text)) - .toEqual(['child task', 'and then?']) - expect(userMessages.map(event => event.data.source)) - .toEqual([{ kind: 'user' }, coordinatorSource]) - }) - - it('reconstructs the declared composition on cold resume', async () => { - const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) - const spec = { - provider: 'spawn', - label: 'scoped delegation', - request: { - prompt: [{ type: 'text' as const, text: 'child task' }], - parent, - persona: 'You are the resumable child.', - toolFilter: { deny: [] as string[] }, - }, - } - const started = ctx.subagents.startContinuable(spec) - await waitTerminal(ctx, started.taskId, parent) - - const loaded = await ctx.sessionPersistence.load(started.childId) - const descriptor = loaded.events.find((event): event is SessionEvent<'subagent/descriptor'> => event.type === 'subagent/descriptor') - expect(descriptor?.data.persona).toBe('You are the resumable child.') - expect(descriptor?.data.toolFilter).toEqual({ deny: [] }) - - const followUp = await followup(ctx, parent, started.childId, message('continue')) - const snapshot = await waitTerminal(ctx, followUp.taskId, parent) - expect(snapshot.status).toBe('completed') - // The resumed child's system prompt carried the persona back. - const resumed = await ctx.sessionPersistence.load(started.childId) - const headers = resumed.events.filter((event): event is SessionEvent<'request/header'> => event.type === 'request/header') - expect(headers.at(-1)?.data.header.system).toContain('You are the resumable child.') - }) - - it('fork children resume from their own transcript without re-forking parent history', async () => { - const { ctx, parent } = await setup([ - textResponse('parent turn one'), - textResponse('fork first answer'), - textResponse('parent turn two'), - textResponse('fork second answer'), - ]) - parent.followup(createUserMessage({ content: message('parent question one'), source: { kind: 'user' } })) - await parent.whenIdle() - - const started = ctx.subagents.startContinuable(startSpec(parent, 'fork')) - await waitTerminal(ctx, started.taskId, parent) - const firstLoad = await ctx.sessionPersistence.load(started.childId) - const seedLength = firstLoad.meta.seedLength ?? 0 - expect(seedLength).toBeGreaterThan(0) - - // The parent gains NEW history the resume must not re-fork. - parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } })) - await parent.whenIdle() - - const followUp = await followup(ctx, parent, started.childId, message('follow up')) - await waitTerminal(ctx, followUp.taskId, parent) - const resumed = await ctx.sessionPersistence.load(started.childId) - // The persisted seed boundary is unchanged and parent turn two is absent. - expect(resumed.meta.seedLength).toBe(seedLength) - const texts = resumed.events - .filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message') - .map(event => (event.data.content[0] as { text: string }).text) - expect(texts).toContain('parent question one') - expect(texts).not.toContain('parent question two') - }) - - it('a resumed child cannot regain a top-level delegation budget (header floor)', async () => { - const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) - const followUp = await followup(ctx, parent, started.childId, message('go on')) - - const childAgents: Agent[] = [] - const stop = ctx.on('agent/created', (agent: Agent) => { - if (agent.id === started.childId) childAgents.push(agent) - }) - await waitTerminal(ctx, followUp.taskId, parent) - stop() - // The resumed runtime options carry no depth, so the header keeps the floor. - const resumedChild = childAgents.at(-1) - expect(resumedChild).toBeDefined() - expect(resumedChild!.session.header.delegationDepth).toBe(1) - }) - - it('rejects a foreign child id: the started Task fails with UNAUTHORIZED and delivers nothing', async () => { - const { ctx, parent } = await setup([textResponse('other parent answer'), textResponse('unused')]) - const otherParent = ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' }) - const started = ctx.subagents.startContinuable(startSpec(otherParent)) - await waitTerminal(ctx, started.taskId, otherParent) - - const attempt = await followup(ctx, parent, started.childId, message('mine now')) - expect(attempt.route).toBe('started') - const snapshot = await waitTerminal(ctx, attempt.taskId, parent) - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('another parent session') - }) - - it('rejects a persisted child with no descriptor as not resumable', async () => { - const { ctx, parent } = await setup([textResponse('plain child')]) - // A plain (non-continuable) child session persisted under this parent. - const handle = await ctx.agents.create({ - sessionId: SessionId('plain-child'), - meta: { parentSession: parent.id, delegationDepth: 1 }, - agentOptions: { provider: 'mock', model: 'mock' }, - }) - handle.agent.followup(createUserMessage({ content: message('do something'), source: { kind: 'user' } })) - await handle.agent.whenIdle() - await handle.dispose() - - const attempt = await followup(ctx, parent, SessionId('plain-child'), message('continue?')) - const snapshot = await waitTerminal(ctx, attempt.taskId, parent) - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain( - 'has no supported continuation state and cannot be resumed; do not retry send_message with this id', - ) - }) - - it('derives fallback and bounded labels for resumed activations', async () => { - const { ctx, parent } = await setup([]) - const blank = await followup(ctx, parent, SessionId('blank-child'), message(' ')) - const longText = 'x'.repeat(100) - const long = await followup(ctx, parent, SessionId('long-child'), message(longText)) - - expect(ctx.tasks.get(blank.taskId, parent).label).toBe('subagent follow-up') - expect(ctx.tasks.get(long.taskId, parent).label).toBe(`${'x'.repeat(79)}…`) - await Promise.all([ - waitTerminal(ctx, blank.taskId, parent), - waitTerminal(ctx, long.taskId, parent), - ]) - }) - - it('rejects delivery to a live agent outside continuation ownership', async () => { +describe('continuable errors', () => { + it('rejects a second live Activation for the same durable child', async () => { const { ctx, parent } = await setup([textResponse('unused')]) - // A live child created outside continuation orchestration. - const handle = await ctx.agents.create({ - sessionId: SessionId('rogue-child'), - meta: { parentSession: parent.id }, - agentOptions: { provider: 'mock', model: 'mock' }, - }) - await expect(followup(ctx, parent, SessionId('rogue-child'), message('hello'))) + // Occupy the id with an unmanaged live Agent. + const squatter = ctx.agentLoop.create(SessionId('squatted'), { provider: 'mock', model: 'mock' }) + await ctx.sessions.flush(squatter.session) + await expect(followup(ctx, { kind: 'user' }, SessionId('squatted'), message('hello'))) .rejects.toThrow(SubagentError) - await expect(followup(ctx, parent, SessionId('rogue-child'), message('hello'))) - .rejects.toThrow(/outside continuation ownership.*not delivered/) - await handle.dispose() - }) - - it('does not fall through to cold resume when steering loses the admission race', async () => { - // Deterministic race: hold run disposal open so the association still - // names a run whose child turn has already ended. - const { ctx, parent } = await setup([textResponse('quick answer'), textResponse('unused')]) - let releaseDispose!: () => void - const disposeGate = new Promise((resolve) => { releaseDispose = resolve }) - const provider = ctx.subagents.getProvider('spawn')! - const realStart = provider.start.bind(provider) - provider.start = async (request) => { - const run = await realStart(request) - const realDispose = run.dispose.bind(run) - return { - ...run, - ...run.steer !== undefined ? { steer: run.steer.bind(run) } : {}, - dispose: async () => { - await disposeGate - return realDispose() - }, - } - } - - const started = ctx.subagents.startContinuable(startSpec(parent)) - // Wait for the child to finish its turn while the run remains undisposed - // and the association therefore still holds. - await new Promise((resolve) => { - const timer = setInterval(() => { - const child = ctx.agents.get(started.childId) - if (child !== undefined && child.status === 'idle' - && child.session.events.some(event => event.type === 'turn/end')) { - clearInterval(timer) - resolve() - } - }, 5) - }) - - // Confirmed steering finds the settled child, fails loud, and does NOT start - // a cold resume within this call. - await expect(followup(ctx, parent, started.childId, message('too late?'))) - .rejects.toThrow(/not delivered/) - expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId]) - releaseDispose() - await waitTerminal(ctx, started.taskId, parent) - // AFTER the Task settles, retry legitimately starts the next activation. - const retry = await followup(ctx, parent, started.childId, message('retry')) - expect(retry.route).toBe('started') - await waitTerminal(ctx, retry.taskId, parent) - }) - - it('each follow-up Task result is fenced to the parent session', async () => { - const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) - const followUp = await followup(ctx, parent, started.childId, message('more')) - const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' }) - expect(() => ctx.tasks.get(followUp.taskId, other)).toThrow(/belongs to another session/) - }) - - it('kills a cold-resume activation during descriptor lookup without starting child work', async () => { - const { ctx, parent } = await setup([textResponse('first'), textResponse('never used')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) - - // Make the persistence load hang until the kill lands. - const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) - let releaseLoad!: () => void - const gate = new Promise((resolve) => { releaseLoad = resolve }) - ctx.sessionPersistence.load = async (id) => { - await gate - return realLoad(id) - } - - const followUp = await followup(ctx, parent, started.childId, message('follow up')) - expect(ctx.tasks.kill(followUp.taskId, parent)).toBe('requested') - releaseLoad() - const snapshot = await waitTerminal(ctx, followUp.taskId, parent) - expect(snapshot.status).toBe('killed') - // Cancellation during lookup prevented any child publication. - expect(ctx.agents.get(started.childId)).toBeUndefined() - }) - - it('admits one process-local activation per child: a second send during resume load steers or fails, never duplicates', async () => { - const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed answer')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) - - const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) - let releaseLoad!: () => void - const gate = new Promise((resolve) => { releaseLoad = resolve }) - ctx.sessionPersistence.load = async (id) => { - await gate - return realLoad(id) - } - - const first = await followup(ctx, parent, started.childId, message('first follow-up')) - expect(first.route).toBe('started') - // The association is installed synchronously, so the competing caller - // observes the pending activation instead of starting a duplicate resume. - await expect(followup(ctx, parent, started.childId, message('second follow-up'))) - .rejects.toThrow(/not delivered/) - releaseLoad() - const snapshot = await waitTerminal(ctx, first.taskId, parent) - expect(snapshot.status).toBe('completed') - // Exactly one follow-up Task was created. - expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId, first.taskId]) - }) -}) - -describe('service disposal with live activations', () => { - it('cancels and settles a starting activation on service disposal instead of stranding it', async () => { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-hmr-')) - roots.push(root) - await ctx.plugin(JsonlSessionPersistence, { root }) - await ctx.plugin(AgentLoop, { agents: [] }) - const subagentsFiber = await ctx.plugin(SubagentService) - await ctx.plugin(LocalTaskService) - await ctx.plugin(ToolTasks, {}) - // A provider that stays pending until its signal aborts, so the activation - // is observably mid-start when the subagent service is disposed. - let sawAbort = false - ctx.subagents.registerProvider({ - name: 'pending', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start: request => new Promise((_resolve, reject) => { - request.signal.addEventListener('abort', () => { - sawAbort = true - reject(new Error('startup aborted')) - }, { once: true }) - }), - resume: () => Promise.reject(new Error('unreachable')), - }) - ctx.llm.registerAdapter(['mock'], new MockAdapter([])) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - - const started = ctx.subagents.startContinuable({ - provider: 'pending', - label: 'will be interrupted', - request: { prompt: message('go'), parent }, - }) - // LocalTaskService keeps the producer Task; the disposing subagent service must - // cancel its activation and await settlement rather than strand it. - await subagentsFiber.dispose() - expect(sawAbort).toBe(true) - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('killed') - }) -}) - -describe('outcome mapping helpers', () => { - it.each([ - ['completed', { status: 'completed', output: 'partial' }], - ['aborted', { status: 'killed' }], - ['error', { status: 'failed', detail: 'error' }], - ['max-tokens', { status: 'failed', detail: 'max-tokens' }], - ['refusal', { status: 'failed', detail: 'refusal' }], - ['paused', { status: 'failed', detail: 'paused' }], - ] as const)('settleRun maps the %s stop reason onto its Task outcome', async (stopReason, expected) => { - const output = [{ type: 'text' as const, text: 'partial' }] - await expect(settleRun({ - id: SessionId('child'), - localAgent: undefined, - result: Promise.resolve({ output, stopReason: stopReason as never }), - dispose: () => Promise.resolve(), - })).resolves.toEqual(expected) - }) - - it('settleRun disposes the run before reporting, on both result paths', async () => { - const order: string[] = [] - const completed = await settleRun({ - id: SessionId('child-1'), - localAgent: undefined, - result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }), - dispose() { order.push('dispose'); return Promise.resolve() }, - }) - order.push('reported') - expect(completed).toEqual({ status: 'completed', output: 'ok' }) - expect(order).toEqual(['dispose', 'reported']) - - // An infrastructure rejection still disposes and reports failed. - let disposed = false - const failed = await settleRun({ - id: SessionId('child-2'), - localAgent: undefined, - result: Promise.reject(new Error('transport gone')), - dispose() { disposed = true; return Promise.resolve() }, - }) - expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' }) - expect(disposed).toBe(true) - - const durabilityMessage = 'subagent "child-3" durability checkpoint failed; latest state unavailable: disk full' - const durabilityFailed = await settleRun({ - id: SessionId('child-3'), - localAgent: undefined, - result: Promise.reject(new HarnessError( - durabilityMessage, - 'DURABILITY_FAILED', - { cause: new Error('disk full') }, - )), - dispose: () => Promise.resolve(), - }) - expect(durabilityFailed).toEqual({ status: 'failed', detail: durabilityMessage }) - - const disposeFailed = await settleRun({ - id: SessionId('child-4'), - localAgent: undefined, - result: Promise.resolve({ output: [], stopReason: 'completed' }), - dispose: () => Promise.reject(new Error('reap failed')), - }) - expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' }) - - const bothFailed = await settleRun({ - id: SessionId('child-5'), - localAgent: undefined, - result: Promise.reject(new Error('result failed')), - dispose: () => Promise.reject(new Error('reap failed')), - }) - expect(bothFailed).toEqual({ - status: 'failed', - detail: 'Error: result failed; dispose failed: Error: reap failed', - }) + void parent }) }) From 357f317b4cddab70010a5c8e2d14b056489b5680 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:33:46 +0800 Subject: [PATCH 051/114] test(subagent): update service, send_message, and delegation specs The continuable path has no Task, so send_message reports a queued next turn and continuable delegation returns only the durable child id. Pins that a follow-up queues behind an open turn rather than steering it, and that a non-parent caller is rejected. Also makes startContinuable/followup reject rather than throw synchronously when continuation services are absent, so callers have one failure mode. --- packages/subagent/subagent/src/index.ts | 6 +- .../subagent/subagent/tests/service.spec.ts | 31 ++-- .../tests/tool-subagent-control.spec.ts | 133 ++++++++---------- .../tool-subagent/tests/tool-subagent.spec.ts | 37 +++-- 4 files changed, 101 insertions(+), 106 deletions(-) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0bfebc8cf5..3be59f2266 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -198,7 +198,7 @@ export class SubagentService extends Service { * @returns the durable child id and the accepted prompt's message id. * @throws when continuation services are unavailable or materialization fails. */ - startContinuable(spec: ContinuableStartSpec): Promise { + async startContinuable(spec: ContinuableStartSpec): Promise { return this.requireContinuations().startContinuable(spec) } @@ -217,7 +217,7 @@ export class SubagentService extends Service { * @throws when continuation services are unavailable, authority is rejected, * or the message was not admitted. */ - followup( + async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], @@ -342,7 +342,7 @@ export class SubagentService extends Service { private requireContinuations(): SubagentContinuationManager { if (this.continuations === undefined) { throw new SubagentError( - 'continuable subagents require the tasks and agents services', + 'continuable subagents require the agents service', 'CONTINUATION_UNAVAILABLE', ) } diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index e86de737ed..8b68e9554f 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -12,7 +12,6 @@ import SubagentService, { assertSubagentMaxDepth, type SubagentCapabilities, type SubagentProvider, - type SubagentProviderStartRequest, type SubagentResult, type SubagentRun, type SubagentStartRequest, @@ -38,7 +37,7 @@ function baseRequest(overrides: Partial = {}): SubagentSta class StubProvider implements SubagentProvider { readonly inheritsParentContext = false startCount = 0 - lastRequest: SubagentProviderStartRequest | undefined + lastRequest: SubagentStartRequest | undefined constructor( readonly name: string, @@ -49,7 +48,7 @@ class StubProvider implements SubagentProvider { }, ) {} - async start(request: SubagentProviderStartRequest): Promise { + async start(request: SubagentStartRequest): Promise { this.startCount += 1 this.lastRequest = request return { @@ -112,21 +111,27 @@ describe('SubagentService', () => { const request = baseRequest() await subagents.start('one-shot', request) + // One-shot start borrows the caller's exact request; the seam has no + // provider-facing resume or steer surface to dispatch through. expect(provider.lastRequest).toBe(request) - expectTypeOf() - .not.toExtend[1]>() + expectTypeOf[1]>().toExtend() expect('resume' in subagents).toBe(false) + expect('resume' in provider).toBe(false) }) - it('rejects Task-backed continuation operations when their runtime services are absent', async () => { + it('rejects continuable operations when their runtime services are absent', async () => { const { subagents } = await service() - expect(() => { - subagents.startContinuable({ - provider: 'unused', - label: 'work', - request: baseRequest(), - }) - }).toThrow(expect.objectContaining({ code: 'CONTINUATION_UNAVAILABLE' })) + await expect(subagents.startContinuable({ + provider: 'unused', + request: baseRequest(), + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) + await expect(subagents.followup( + { kind: 'user' }, + SessionId('child'), + [{ type: 'text', text: 'hello' }], + { source: { kind: 'user' }, signal: new AbortController().signal }, + )).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) }) it.each([ diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index b035fa1127..3b1225f63e 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -10,8 +10,6 @@ import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' -import LocalTaskService from '@deepseek-ai/dsh-tasks-local' -import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as tool from '../src/index.ts' @@ -31,8 +29,6 @@ async function setup(script: ConstructorParameters[0]) { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) - await ctx.plugin(LocalTaskService) - await ctx.plugin(ToolTasks, {}) await ctx.plugin(tool) const adapter = new MockAdapter(script) ctx.llm.registerAdapter(['mock'], adapter) @@ -61,6 +57,13 @@ function callTool( }) } +/** Wait until a child's Activation released its handle. */ +async function waitNoActivation(ctx: Context, childId: SessionId): Promise { + await vi.waitFor(() => { + expect(ctx.agents.get(childId)).toBeUndefined() + }, { timeout: 5_000 }) +} + describe('dsh-tool-subagent-control', () => { it('registers send_message once, globally, with the two required parameters', async () => { const { ctx } = await setup([]) @@ -68,90 +71,62 @@ describe('dsh-tool-subagent-control', () => { expect(schemas).toHaveLength(1) const props = (schemas[0]!.parameters as { properties?: Record }).properties ?? {} expect(Object.keys(props).sort()).toEqual(['message', 'subagent_id']) - expect(schemas[0]!.description).toContain('task_output') + // The continuable path has no Task, so the schema must not promise one. + expect(schemas[0]!.description).not.toContain('task_output') + expect(schemas[0]!.description).not.toContain('task id') + // Follow-up ordering is model-visible: it cannot redirect the open turn. + expect(schemas[0]!.description).toContain('next turn') }) - it('cold-resumes a settled child and renders the started route with its task id', async () => { + it('cold-resumes a settled child and reports the queued next turn', async () => { const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) - const started = ctx.subagents.startContinuable({ + const started = await ctx.subagents.startContinuable({ provider: 'spawn', - label: 'work', request: { prompt: [{ type: 'text', text: 'child task' }], parent }, + signal: testToolSignal, }) - await ctx.tasks.wait(started.taskId, 5_000, parent) + await waitNoActivation(ctx, started.childId) const result = await callTool(ctx, 'send_message', { subagent_id: started.childId, message: 'and then?', }, parent) + expect(result.isError).toBe(false) - expect(text(result)).toBe(`message started task subagent-2 continuing subagent ${started.childId}`) - const collected = await callTool(ctx, 'task_output', { task_id: 'subagent-2', wait: true }, parent) - expect(text(collected)).toBe('second answer\n[status: completed]') + expect(text(result)).toBe(`message queued as the next turn for subagent ${started.childId}`) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) - const followUp = loaded.events.findLast(event => - event.type === 'user/message', - ) + const followUp = loaded.events.findLast(event => event.type === 'user/message') + // Durable provenance records the calling agent without granting authority. expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({ kind: 'coordinator', senderSessionId: parent.id, }) }) - it('renders the steered route when the child is still running', async () => { - // Script the child's single turn as two steps: the steer joins mid-turn. - const { ctx, parent } = await setup([]) - let steered: string | undefined - let source: unknown - // Reach past the tool into the subagent service to fake a running route - // deterministically: the tool is a thin adapter, so its steered wording is - // what this test pins. - ctx.subagents.followup = async (agent, _childId, message, options) => { - steered = (message[0] as { text: string }).text - source = options.source - return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } - } + it('queues behind an open turn instead of joining it', async () => { + const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')]) + const started = await ctx.subagents.startContinuable({ + provider: 'spawn', + request: { prompt: [{ type: 'text', text: 'long work' }], parent }, + signal: testToolSignal, + }) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const result = await callTool(ctx, 'send_message', { - subagent_id: 'some-child', + subagent_id: started.childId, message: 'also consider Y', }, parent) expect(result.isError).toBe(false) - expect(steered).toBe('also consider Y') - expect(source).toEqual({ kind: 'coordinator', senderSessionId: parent.id }) - expect(text(result)).toBe('message delivered to running task subagent-9') - }) - it('cancels a pending live-delivery wait when the tool signal aborts', async () => { - const { ctx, parent, adapter } = await setup(['hang']) - const started = ctx.subagents.startContinuable({ - provider: 'spawn', - label: 'hung work', - request: { prompt: [{ type: 'text', text: 'wait' }], parent }, - }) - await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - const deliveryStarted: PromiseWithResolvers = Promise.withResolvers() - const followup = ctx.subagents.followup.bind(ctx.subagents) - ctx.subagents.followup = (agent, childId, message, options) => { - const delivery = followup(agent, childId, message, options) - deliveryStarted.resolve() - return delivery - } - - const controller = new AbortController() - const execution = callTool(ctx, 'send_message', { - subagent_id: started.childId, - message: 'follow up', - }, parent, controller.signal) - await deliveryStarted.promise - controller.abort('parent tool cancelled') - - const result = await execution - expect(result.isError).toBe(true) - expect(result.error?.info?.code).toBe('CANCELLED') - expect(ctx.agents.get(started.childId)).toBeUndefined() - const snapshot = await ctx.tasks.wait(started.taskId, 5_000, parent) - expect(snapshot.status).toBe('killed') + await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) + const prompts = loaded.events.flatMap(event => event.type === 'user/message' + ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) + : []) + // A follow-up is its own later turn, never steering inside the first one. + expect(prompts).toEqual(['long work', 'also consider Y']) expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) }) @@ -161,17 +136,26 @@ describe('dsh-tool-subagent-control', () => { subagent_id: 'no-such-child', message: 'hello?', }, parent) - // Unknown ids start a Task whose failure carries the unavailable detail; - // synchronous rejections (ownership conflicts) become isError results. - if (result.isError) { - expect(text(result)).toContain('not delivered') - } else { - const taskId = text(result).match(/task (\S+) /)?.[1] - expect(taskId).toBeDefined() - const snapshot = await ctx.tasks.wait(taskId as never, 5_000, parent) - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('unavailable') - } + expect(result.isError).toBe(true) + expect(text(result)).toContain('unavailable') + }) + + it('rejects a caller that is not the child\'s durable direct parent', async () => { + const { ctx, parent } = await setup([textResponse('first')]) + const started = await ctx.subagents.startContinuable({ + provider: 'spawn', + request: { prompt: [{ type: 'text', text: 'child task' }], parent }, + signal: testToolSignal, + }) + await waitNoActivation(ctx, started.childId) + const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) + + const result = await callTool(ctx, 'send_message', { + subagent_id: started.childId, + message: 'mine now', + }, stranger) + expect(result.isError).toBe(true) + expect(text(result)).toContain('another parent session') }) it('fails loud when invoked without a calling agent', async () => { @@ -186,7 +170,6 @@ describe('dsh-tool-subagent-control', () => { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(LocalTaskService) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true) await fiber.dispose() diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 1b45640a94..fdcc359447 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -68,7 +68,7 @@ function text(result: { content: { type: string; text?: string }[] }): string { } describe('dsh-tool-subagent', () => { - it('rejects continuable background policy when the configured provider cannot resume', async () => { + it('rejects continuable background policy when the provider cannot prepare continuable children', async () => { let failure: unknown try { await setup({ @@ -668,10 +668,10 @@ describe('dsh-tool-subagent background mode', () => { return ctx } - it('keeps a resumable provider one-shot when backgroundMode selects one-shot', async () => { + it('keeps a continuable-capable provider one-shot when backgroundMode selects one-shot', async () => { const ctx = await backgroundSetup({ provider: 'mock' }) const parent = ownerAgent(ctx, 'sess-parent') - let resumeCalls = 0 + let prepareCalls = 0 ctx.subagents.registerProvider({ name: 'resumable', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, @@ -685,9 +685,9 @@ describe('dsh-tool-subagent background mode', () => { }), dispose: () => Promise.resolve(), }), - resume: async () => { - resumeCalls += 1 - throw new Error('one-shot policy must not resume') + prepareContinuable: async () => { + prepareCalls += 1 + throw new Error('one-shot policy must not prepare a continuable child') }, }) tool.apply(ctx, { @@ -706,7 +706,7 @@ describe('dsh-tool-subagent background mode', () => { }) expect(text(started)).toBe('started background subagent task subagent-1') - expect(resumeCalls).toBe(0) + expect(prepareCalls).toBe(0) }) it('returns a task id immediately and the answer is collected through task_output', async () => { @@ -899,10 +899,13 @@ describe('dsh-tool-subagent continuable background mode', () => { return { ctx, parent } } - it('starts a continuable child and returns both ids without send_message', async () => { + it('starts a continuable child and returns only its durable id, creating no Task', async () => { const { ctx, parent } = await continuableSetup() const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! - expect(schema.description).not.toContain('send_message') + // Continuable delegation has no Task, so the schema promises no collection. + expect(schema.description).not.toContain('task_output') + expect(schema.description).not.toContain('task_kill') + expect(schema.description).toContain('send_message') const started = await callSubagent( ctx, @@ -910,15 +913,19 @@ describe('dsh-tool-subagent continuable background mode', () => { { agent: parent }, ) expect(started.isError).toBe(false) - const match = /^started subagent (\S+) as task (\S+)$/.exec(text(started)) + const match = /^started subagent (\S+)$/.exec(text(started)) expect(match).not.toBeNull() - const [, childId, taskId] = match! - const snapshot = await ctx.tasks.wait(taskId as never, 5_000, parent) - expect(snapshot.status).toBe('completed') - expect(ctx.tasks.read(taskId as never, parent).text).toBe('continuable answer') - // The child id names a durable session that outlives the settled Task. + const [, childId] = match! + // No Task was created for the continuable child. + expect(ctx.tasks.list(parent)).toEqual([]) + + await vi.waitFor(() => { + expect(ctx.agents.get(SessionId(childId!))).toBeUndefined() + }, { timeout: 5_000 }) + // The child id names a durable session carrying its continuation descriptor. const loaded = await ctx.sessionPersistence.load(SessionId(childId!)) expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) + expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true) }) }) From 4e7a5f19cf6e7310f39b68bdfb65c0af1151c16c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:37:22 +0800 Subject: [PATCH 052/114] docs(subagent): rewrite the subagent data-structure doc for activations wip: Chinese pair and remaining generated catalogs follow. --- docs/core-data-structures/subagent.md | 267 ++++++++++++++------------ scripts/gen-tool-catalog.ts | 3 +- scripts/type-equiv.manifest.json | 29 +-- 3 files changed, 162 insertions(+), 137 deletions(-) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 8f24afec47..a58ecf13ba 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,24 +4,25 @@ English | [中文](subagent.zh.md) The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`). The same `ctx.subagents` service owns continuable-child orchestration through an internal Task-backed manager. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable background subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) ## Two kinds of capability, discovered two ways -A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features are instead optional methods whose presence IS the capability, with TS narrowing as the discovery mechanism: confirmed live steering is [`SubagentRun.steer`](#a-live-run-subagentrun) and persisted cold resume is [`SubagentProvider.resume`](#the-provider-seam-subagentprovider). +A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a one-shot run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. Those flags describe only the one-shot [`start()`](#the-provider-seam-subagentprovider) path, where the provider composes the child. **Continuable** children are composed by the continuation manager itself, so they are gated by one optional method whose presence IS the capability, with TS narrowing as the discovery mechanism: [`SubagentProvider.prepareContinuable`](#the-provider-seam-subagentprovider). ```ts type-equiv /** * Which START-TIME features a provider supports. Checked by the service before delegating to * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent - * degradation" rule). These static flags cover features needed before a run exists; runtime - * capabilities are optional methods whose presence is the capability — confirmed live steering - * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each - * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to - * `maxDepth`; the other names match. + * degradation" rule). These flags describe the ONE-SHOT + * {@link SubagentProvider.start} path, where the provider composes the child; + * continuable children are composed by the continuation manager itself and are + * gated by {@link SubagentProvider.prepareContinuable} instead. Each flag + * corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` + * to `maxDepth`; the other names match. */ interface SubagentCapabilities { readonly outputSchema: boolean @@ -31,16 +32,16 @@ interface SubagentCapabilities { } ``` -## The start request +## The one-shot start request The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional output schema, depth, tool filter, and persona require matching capability flags. Unsupported schemas fail at start; in-process backends scope filters and personas to child creation and implement the supported object-rooted schema with a forced capture tool. ```ts type-equiv /** - * What a caller asks for when starting a subagent. The tool layer builds this - * from the model's `{ description, prompt }` plus its own config; the service - * validates {@link SubagentCapabilities} against the named provider and - * resolves a {@link SubagentProviderStartRequest} for dispatch. + * What a caller asks for when starting a ONE-SHOT subagent. The tool layer + * builds this from the model's `{ description, prompt }` plus its own config; + * the service validates {@link SubagentCapabilities} against the named provider + * before dispatching to {@link SubagentProvider.start}. */ interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -94,31 +95,41 @@ interface SubagentStartRequest { `signal` is the single cancellation channel before and after readiness. The [subagent composition-controls Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the persona, live global-tool filter, absolute-depth, and visibility-not-authority rationale. -Providers receive a separate resolved shape. The `SubagentService.start()` parameter type excludes continuation state, while `startContinuable()` alone supplies the service-allocated identity and descriptor. +Providers receive exactly this request: one-shot delegation has no service-resolved continuation state, because a continuable child never reaches `SubagentProvider.start()`. -```ts type-equiv -/** - * Provider-facing start request after the service resolves optional - * continuation state. Ordinary callers use {@link SubagentStartRequest}; only - * the Task-backed continuation path can attach a stable child identity and - * durable descriptor. - */ -interface SubagentProviderStartRequest extends SubagentStartRequest { - /** - * Continuable-child state resolved by `ctx.subagents` before provider dispatch. - * The provider MUST publish exactly `sessionId` as the child identity - * instead of allocating one internally, and MUST append the snapshotted, - * model-hidden `subagent/descriptor` before the initial prompt is admitted. - * Requires {@link SubagentProvider.resume} (the - * continuation capability); the service rejects the request otherwise. - */ - readonly continuation?: SubagentContinuation | undefined -} +## Continuable children and activations + +A **continuable background subagent** is one durable child Session with at most one process-local **Activation** — a residency epoch for a reconstructed child Agent. An Activation is not a request, result, cancellation, or Task boundary: it may execute many FIFO turns and stays resident while descendants it created are still running. The continuation manager owns activation admission, authority, the live ownership graph, cold resume, and child-first disposal; the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper. + +```text +persisted Session + -> optional live Activation + -> one retained AgentHandle + -> Agent inbox as the only turn FIFO + -> zero or more owned child Activations ``` -## Continuable children and provider resume +`SubagentService.startContinuable()` reserves the stable child id, snapshots the versioned `subagent/descriptor` payload, asks the named provider for its detached `ContinuableCreateSpec`, creates the child Agent through a private activation-owner scope, establishes any continuable-parent ownership, and submits the initial prompt. It resolves with `{ childId, messageId }` when inbox acceptance yields the message id — without waiting for the turn to start or for the message to enter the Session log. Every failure before that acceptance rejects with neither id, disposing any created handle and rolling back the Activation and parent ownership. -A **continuable background subagent** is a durable child session with a series of Task-backed activations. `SubagentService.startContinuable()` allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the provider-facing start request; the provider publishes exactly that id and appends the descriptor before the initial prompt is admitted. `SubagentService.followup()` mirrors the intent verb on `Agent`: it steers a live activation or privately dispatches a resolved provider resume after loading and authorizing a stopped child. An internal manager owns descriptor lookup and Task association only while `ctx.tasks` and `ctx.agents` exist; persistence is required per continuation operation, not to load the provider registry. `startContinuable()` returns both identities, while `followup()` reports whether the content `steered` the existing Task or `started` a fresh one. Every sender supplies a `MessageSource` and cancellation signal through one options object; abort while live delivery awaits admission cancels the shared activation and rejects after quiescence. The optional model-facing tool uses `CoordinatorMessageSource` and its tool-execution signal, while a human adapter uses `{ kind: 'user' }` and its interaction signal. +`SubagentService.followup()` is the sole continuation-message operation, and routing depends only on Activation residency: + +| Activation state | Sender | `followup` | +|---|---|---| +| `running` | parent or user | enqueue in the same Activation | +| `waiting` | parent or user | wake the same Activation | +| no Activation | parent or user | cold-resume a new Activation | + +`running` means the Agent has an active admission or turn, or waking inbox work; `waiting` means it is quiescent but still owns at least one child Activation that has not completed disposal; `settled` means quiescent with every owned child disposed, at which point the manager disposes the `AgentHandle` and removes the Activation. The manager derives these from Agent quiescence and the owned-child set rather than maintaining a second execution state machine, and `activationState()` reports the current value (`undefined` when no Activation is live). + +The Agent inbox is the only queue. Every continuation message becomes one `Agent.followup()` FIFO turn, so parent and user messages share one observable order and a follow-up cannot redirect a turn already underway. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/enqueue`, `agent/inbox/dequeue`, and `agent/inbox/discard` events remain the message-lifecycle observations, and the continuation layer defines no subagent-specific delivery route. + +Authority is supplied by a trusted host interaction or an exact live Agent tool context. The parent variant is admitted only when the authenticated Agent is the durable child's direct parent recorded in `SessionHeader.parentSession`; only a trusted host adapter can supply user authority. `MessageSource` and `senderSessionId` are durable provenance after admission and grant no authority — the optional model-facing tool uses `CoordinatorMessageSource`, while a host adapter uses `{ kind: 'user' }`. User authority may cold-resume a child without loading its historical parent. + +For both operations the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child, and the seam exposes no public subagent cancellation or steering operation. + +Every Activation owns its `AgentHandle` and an `ownedChildren: Set`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens only after the child Agent is quiescent, every child of that child is disposed, the final durability checkpoint settles, and the child's `AgentHandle` completes disposal. + +Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. `drainContinuable()` is the lifecycle-wide stop path: it closes admission synchronously, then disposes every live Activation forest child-first, awaiting every branch despite individual failures. Durable child Sessions survive that process-local teardown. ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -131,80 +142,94 @@ interface CoordinatorMessageSource { ```ts type-equiv /** - * Options for following up with one continuable child. + * Who authorizes one continuable-subagent operation. Authority comes from a + * trusted host interaction or an exact live Agent tool context; durable + * {@link MessageSource} provenance never authorizes delivery. */ +type SubagentAuthority = + /** The exact live parent Agent whose tool context is making the call. */ + | { readonly kind: 'parent'; readonly agent: Agent } + /** A trusted host adapter acting for the human user. */ + | { readonly kind: 'user' } +``` + +```ts type-equiv +/** Options for following up with one continuable child. */ interface SubagentFollowupOptions { - /** Durable attribution retained on either live or resumed delivery. */ + /** Durable attribution retained on the delivered message; it grants no authority. */ readonly source: MessageSource - /** Caller cancellation for a live-delivery admission wait. */ + /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal } ``` ```ts type-equiv -/** - * How a continuable follow-up was routed: - * `steered` joined the running activation's existing Task without creating a - * Task of its own; `started` created a fresh Task that cold-resumes the - * durable child with the content. Failure is an exception, never a result — - * undelivered content throws. - */ -type SubagentFollowupResult = - | { readonly route: 'steered'; readonly taskId: TaskId } - | { readonly route: 'started'; readonly taskId: TaskId } -``` - -```ts type-equiv -/** - * The resolved continuable-child identity and durable composition record the - * service attaches before provider dispatch. - */ -interface SubagentContinuation { - /** Service-allocated stable child session id, published verbatim. */ - readonly sessionId: SessionId - /** Snapshotted descriptor persisted in the child log for cold resume. */ - readonly descriptor: SubagentDescriptorData +/** Identities returned once a continuable child accepted its initial prompt. */ +interface ContinuableStart { + /** The durable child session id, stable across activations. */ + readonly childId: SessionId + /** The accepted initial prompt's inbox message id. */ + readonly messageId: MessageId } ``` ```ts type-equiv /** - * Provider-facing request for reconstructing a persisted continuable child. - * The continuation manager loads the child log, folds and authorizes its - * descriptor, then privately dispatches this resolved request to - * {@link SubagentProvider.resume}. The provider reconstructs the declared - * composition under the live parent's scope and drives one turn with `prompt`. + * The public residency state of one continuable child, derived from Agent + * quiescence and the owned-child set rather than a second state machine: + * `running` — the Agent has an active admission or turn, or waking inbox work; + * `waiting` — the Agent is quiescent but still owns undisposed children; + * `settled` — quiescent with every owned child disposed, so the manager + * disposes the `AgentHandle` and removes the Activation. */ -interface SubagentProviderResumeRequest { - /** The persisted child session id to resume. */ +type ActivationState = 'running' | 'waiting' | 'settled' +``` + +The provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its returned spec carries only detached provider-specific creation inputs — today the optional parent-history seed — and no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. Cold resume does not dispatch through a provider at all: the manager folds the generic descriptor, calls `ctx.agents.resume()` through the same activation-owner scope, and submits the waiting turn. + +```ts type-equiv +/** + * What the continuation manager asks a provider for while materializing one + * continuable child's FIRST activation. The manager has already reserved the + * durable child identity and owns every later operation, so this request + * carries only what distinguishes a fresh child from one seeded with parent + * history. + */ +interface ContinuableCreateRequest { + /** The reserved durable child session id, for provider diagnostics. */ readonly sessionId: SessionId - /** The follow-up message that starts the resumed activation's turn. */ - readonly prompt: ContentBlock[] - /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ - readonly source: MessageSource - /** - * The live parent agent — the direct parent recorded in the persisted child - * header. In-process backends reconstruct the child under this agent's - * currently loaded scope. - */ + /** The delegating parent agent whose history a seeding provider reads. */ readonly parent: Agent /** - * Activation-owned cancellation signal, created before descriptor lookup. - * Same pre/post-publication contract as {@link SubagentStartRequest.signal}: - * an abort before publication rejects after rollback quiescence, and an - * abort afterward cancels the published child turn. + * Caller cancellation, which owns preparation only until the manager accepts + * the initial prompt into the child's inbox. */ readonly signal: AbortSignal - /** The folded durable descriptor whose composition the provider reconstructs. */ - readonly descriptor: SubagentDescriptorData } ``` -The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) snapshots explicit fields — provider name, resolved child `agentOptions.provider`/`model`, optional `persona`/`toolFilter` — never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (an activation's result contract, not durable composition). The `subagent/descriptor` event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. +```ts type-equiv +/** + * A provider's detached contribution to one continuable child's creation. This + * is DATA, never a capability: it carries no Agent, `AgentHandle`, prompt + * delivery, result, disposal, or resume operation, because the continuation + * manager owns the child's whole lifecycle after preparation. + */ +interface ContinuableCreateSpec { + /** + * Completed-turn prefix of the parent's log to seed the child session with, + * or absent for a fresh child. Same durable contract as + * `CreateAgentOptions.seed`: contiguous from seq 0, lossless JSON, balanced. + */ + readonly seed?: readonly SessionEvent[] +} +``` + +The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) snapshots explicit fields — provider name, resolved child `agentOptions.provider`/`model`, optional `persona`/`toolFilter` — never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (a one-shot result contract, not durable composition). The continuation manager appends the model-hidden `subagent/descriptor` event after any provider-supplied lineage and before the initial prompt is admitted; `header.seedLength` remains the fork-lineage boundary, so descriptor lookup reads the child's own suffix. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. ## The terminal result: `SubagentResult` -The outcome of a run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. +The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. ```ts type-equiv /** @@ -249,15 +274,18 @@ interface SubagentStopReasonMap { } ``` -## A live run: `SubagentRun` +## A one-shot run: `SubagentRun` -`SubagentRun` is the consumer-owned handle for a ready child — one disposable activation, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. A completed continuable result additionally means the provider confirmed the activation's final state durable; a failed required checkpoint rejects. The optional confirmed `steer` method advertises live delivery by presence and fulfills only after a request snapshot admits the message. Cold resume is a provider-level operation: `SubagentProvider.resume` reconstructs a fresh run from the child's persisted session because the process-local run ceases to exist after disposal or process restart. +`SubagentRun` is the consumer-owned handle for a ready one-shot child — one disposable foreground delegation with one result, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. A run has no steering and no resume: continuable conversations have no run at all, because the continuation manager holds their `AgentHandle` directly and orders every turn through the child's own inbox. ```ts type-equiv /** - * Child handle returned only after readiness. Consumers await {@link result} and must always - * {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime - * capability discovery; narrow their presence before calling. + * ONE-SHOT child handle returned only after readiness. Consumers await + * {@link result} and must always {@link dispose} to cancel remaining work and + * reach quiescence. A run is one disposable foreground delegation with one + * result; continuable conversations have no run — the continuation manager + * holds their `AgentHandle` directly and orders every turn through the child's + * own inbox. */ interface SubagentRun { /** @@ -276,10 +304,8 @@ interface SubagentRun { * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport * failure resolves with `stopReason: 'error'` so the consumer maps it to an - * `isError` tool result. For a continuable activation, a completed result - * also means the provider confirmed the activation's final state durable. - * Rejects on an infrastructure fault the seam cannot represent as a stop - * reason, including a failed required durability checkpoint. + * `isError` tool result. Rejects on an infrastructure fault the seam cannot + * represent as a stop reason. */ readonly result: Promise /** @@ -287,25 +313,14 @@ interface SubagentRun { * Idempotent. */ dispose(): Promise - /** - * OPTIONAL (confirmed live-steering capability): submit additional content - * to the active child and fulfill only after a committed request snapshot - * admits it. Rejects when terminal policy, cancellation, disposal, or a lost - * settlement race prevents admission; it never falls through to a queued - * untracked turn or cold resume. A run represents one disposable activation, - * so resuming a settled child goes through {@link SubagentProvider.resume}. - * `source` is retained on the admitted steering message without changing its - * user role in model history. - */ - steer?(content: ContentBlock[], source: MessageSource): Promise } ``` -A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`. +A local one-shot run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`. ## The provider seam: `SubagentProvider` -Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority. +Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`, and rejects a continuable start on a provider without `prepareContinuable`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority. ```ts type-equiv /** @@ -325,33 +340,37 @@ interface SubagentProvider { */ readonly inheritsParentContext: boolean /** - * Establish a child and return its handle only after publication. The - * service has already validated that every requested start-time capability - * is supported, so an implementation may assume e.g. `request.maxDepth` is - * honorable when present. If setup fails or `request.signal` aborts before - * fulfillment, the provider owns and cleans all partial resources before this - * promise rejects. Ownership transfers to the caller only on fulfillment. + * Establish a ONE-SHOT child and return its handle only after publication. + * The service has already validated that every requested start-time + * capability is supported, so an implementation may assume e.g. + * `request.maxDepth` is honorable when present. If setup fails or + * `request.signal` aborts before fulfillment, the provider owns and cleans + * all partial resources before this promise rejects. Ownership transfers to + * the caller only on fulfillment. */ - start(request: SubagentProviderStartRequest): Promise + start(request: SubagentStartRequest): Promise /** - * OPTIONAL (continuation capability): reconstruct a persisted continuable - * child from its own transcript and declared descriptor, drive one - * follow-up turn, and return a fresh run. Method presence is the capability - * — the service rejects continuable starts and cold-resume dispatch on - * providers without it. Same publication contract as {@link start}: if - * reconstruction fails or `request.signal` aborts before fulfillment, the - * provider rolls its creation transaction back to quiescence before - * rejecting; after fulfillment the same signal cancels the published run. + * OPTIONAL (continuable-creation capability): contribute the detached + * creation inputs that distinguish this provider's continuable children — + * today only whether the child session is seeded with parent history. Method + * presence IS the capability: the service rejects continuable starts on + * providers without it, while a provider that has it may still serve + * ordinary one-shot delegations. + * + * This is the provider's ONLY participation in a continuable child. The + * continuation manager owns identity reservation, composition, Agent + * creation, prompt delivery, cold resume, ownership, and disposal, so a + * provider never sees the child's Agent, handle, turns, or teardown. */ - resume?(request: SubagentProviderResumeRequest): Promise + prepareContinuable?(request: ContinuableCreateRequest): Promise } ``` -Provider `start()` fulfills only with a ready run; provider `resume()` shares the same publication and lifecycle-observation contract but is dispatched only by the continuation manager. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. +Provider `start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. Each continuable Activation emits the same observe-only pair for its residency epoch, so a cold resume is a new epoch with its own `runId`. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. ## In-process backends: depth and seed -The spawn and fork backends create an ordinary agent through `parent.ctx`, pass cancellation into core creation, and dispose through `AgentHandle`. Provider removal blocks new starts without revoking accepted runs. Each child gets a new flat scope rather than inheriting parent registrations. Depth and fork seeding reuse existing agent and session vocabulary: +The spawn and fork backends create an ordinary one-shot agent through `parent.ctx`, pass cancellation into core creation, and dispose through `AgentHandle`; a continuable child is instead created by the continuation manager through its own activation-owner scope. Provider removal blocks new starts without revoking accepted runs. Each child gets a new flat scope rather than inheriting parent registrations. Depth and fork seeding reuse existing agent and session vocabulary: -- **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap. -- **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). +- **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, cold resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap. +- **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `ctx.agents.resume()` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 96d6a97457..56b10b84b3 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -107,7 +107,8 @@ function registerCatalogSubagentProvider(ctx: Context, name: string): void { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')), - resume: () => Promise.reject(new Error('tool-catalog provider cannot resume a child')), + // Declared so consumers configured for continuable background mode mount. + prepareContinuable: () => Promise.reject(new Error('tool-catalog provider cannot prepare a child')), } ctx.subagents.registerProvider(provider) } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0a17861a07..e9b60aa650 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1094,16 +1094,6 @@ "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, - { - "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentProviderStartRequest", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentContinuation", - "source": "packages/subagent/subagent/src/types.ts" - }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "CoordinatorMessageSource", @@ -1116,12 +1106,27 @@ }, { "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentFollowupResult", + "symbol": "SubagentAuthority", "source": "packages/subagent/subagent/src/continuation.ts" }, { "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentProviderResumeRequest", + "symbol": "ContinuableStart", + "source": "packages/subagent/subagent/src/continuation.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "ActivationState", + "source": "packages/subagent/subagent/src/continuation.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "ContinuableCreateRequest", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "ContinuableCreateSpec", "source": "packages/subagent/subagent/src/types.ts" }, { From 72f8f4733532d5674eba06dd8e1d71882967477b Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:40:45 +0800 Subject: [PATCH 053/114] docs(subagent): land the continuable-subagents note and supersede its predecessors Moves the RFC to implemented/, restates it as current-state prose under the implemented note format, and records what the Task-backed continuable-subagents note and the two subagent-service simplification notes retain versus what this record replaces. --- ...continuable-background-subagents.i18n.yaml | 4 +-- ...-07-21-continuable-background-subagents.md | 2 +- ...-21-continuable-background-subagents.zh.md | 2 +- ...ntinuable-subagent-conversations.i18n.yaml | 6 +++++ ...7-28-continuable-subagent-conversations.md | 26 ++++++++++--------- ...8-continuable-subagent-conversations.zh.md | 26 ++++++++++--------- ...6-merge-subagent-control-service.i18n.yaml | 4 +-- ...26-07-26-merge-subagent-control-service.md | 2 +- ...07-26-merge-subagent-control-service.zh.md | 2 +- ...subagent-continuation-operations.i18n.yaml | 4 +-- ...-named-subagent-continuation-operations.md | 2 ++ ...med-subagent-continuation-operations.zh.md | 2 ++ ...ntinuable-subagent-conversations.i18n.yaml | 6 ----- 13 files changed, 48 insertions(+), 40 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml rename .agents/notes/{proposed => implemented}/feature/2026-07-28-continuable-subagent-conversations.md (94%) rename .agents/notes/{proposed => implemented}/feature/2026-07-28-continuable-subagent-conversations.zh.md (93%) delete mode 100644 .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 8813e2d10b..0694137ab0 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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-07-21-continuable-background-subagents.md -2026-07-21-continuable-background-subagents.md: 0ea085a3eb9c6e661c1f009f338b264c06f14983 -2026-07-21-continuable-background-subagents.zh.md: 93f4c5b8ba4a052c5a6bb6eac3802601eb0797a5 +2026-07-21-continuable-background-subagents.md: 513ee668a9e04c05bb50f946016c460e09d1ddcd +2026-07-21-continuable-background-subagents.zh.md: 88cdd00582b18a2092c6993507fe3d4b92f237ae diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index 0ea085a3eb..513ee668a9 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-21-continuable-background-subagents.zh.md) -The service-placement and provider-capability policy in this record are superseded by [Merge subagent control into the subagent service](../simplification/2026-07-26-merge-subagent-control-service.md) and [Intent-named subagent continuation operations](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md). The continuation, persistence, Task, routing, authorization, and durability semantics remain in force. +This record is superseded by [Continuable subagents](2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed activation model, routing, cancellation, and durability semantics with one durable Session plus at most one process-local Activation. Its service-placement and provider-capability policy were already superseded by [Merge subagent control into the subagent service](../simplification/2026-07-26-merge-subagent-control-service.md) and [Intent-named subagent continuation operations](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md). Only the durable child-session and descriptor rationale remains in force. ## Problem diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 93f4c5b8ba..88cdd00582 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-21-continuable-background-subagents.md) | 中文 -本记录中的服务放置与提供方功能策略已由[将 subagent 控制合并到 subagent 服务](../simplification/2026-07-26-merge-subagent-control-service.md)和[以意图命名的 subagent 继续执行操作](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md)取代。继续执行、持久化、Task、路由、授权和持久性语义仍然有效。 +本记录已由[可继续的 subagent](2026-07-28-continuable-subagent-conversations.md)取代——后者以一个持久 Session 加至多一个进程内 Activation(驻留期)替换了其基于 Task 的 activation 模型、路由、取消和持久性语义。其服务放置与提供方功能策略此前已由[将 subagent 控制合并到 subagent 服务](../simplification/2026-07-26-merge-subagent-control-service.md)和[以意图命名的 subagent 继续执行操作](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md)取代。仅持久 child 会话与 descriptor 的设计依据仍然有效。 ## 问题 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml new file mode 100644 index 0000000000..f9a3bfc16c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +2026-07-28-continuable-subagent-conversations.md: 5ab17ea13d15d66afab4fee6766b082dd207b8a3 +2026-07-28-continuable-subagent-conversations.zh.md: eb14ebcec9682432682f6b5b4d8399f35b6882a2 diff --git a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md similarity index 94% rename from .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md rename to .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index 3902fbc330..5ab17ea13d 100644 --- a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -1,6 +1,6 @@ # Agent Note: Continuable subagents -Status: proposed +Status: implemented English | [中文](2026-07-28-continuable-subagent-conversations.zh.md) @@ -16,7 +16,7 @@ The runtime lifetime is also wider than one turn. A subagent can finish its own Users and parent Agents also need to send later work to the same live child without changing its current turn. Queueing every continuation message as a follow-up preserves one ordering rule for both senders. -## Proposal +## Decision A continuable subagent has one durable Session and at most one process-local Activation: @@ -34,7 +34,7 @@ The continuation manager owns activation admission, authority checks, the live o ### Materialization and public operations -The named subagent provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its optional `prepareContinuable(request): Promise` method is the continuable-creation capability. The returned spec contains only detached provider-specific creation inputs such as the optional parent-history seed; it contains no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. The manager reserves the child identity, resolves the durable descriptor and common Agent setup, calls `ctx.agents.create()` through a private activation-owner scope, installs the returned `AgentHandle` into the Activation, establishes any continuable-parent ownership, and then calls `Agent.followup(initialPrompt)`. Inbox acceptance yields an `AgentMessageId`; at that boundary `ctx.subagents.startContinuable()` returns `{ childId, messageId }` without waiting for the turn to start or for the message to enter the Session log. +The named subagent provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its optional `prepareContinuable(request): Promise` method is the continuable-creation capability. The returned spec contains only detached provider-specific creation inputs such as the optional parent-history seed; it contains no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. The manager reserves the child identity, resolves the durable descriptor and common Agent setup, calls `ctx.agents.create()` through a private activation-owner scope, installs the returned `AgentHandle` into the Activation, establishes any continuable-parent ownership, and then calls `Agent.followup(initialPrompt)`. Inbox acceptance yields an `MessageId`; at that boundary `ctx.subagents.startContinuable()` returns `{ childId, messageId }` without waiting for the turn to start or for the message to enter the Session log. Any failure before inbox acceptance rejects without returning either id. Agent creation provides rollback before handle transfer; after transfer, the manager disposes the created handle, removes the Activation, and rolls back any parent `ownedChildren` membership before rejecting. @@ -44,9 +44,9 @@ Cold resume does not dispatch through a subagent provider. The continuation mana `SubagentProvider.start()` and `SubagentRun` remain exclusively on the unchanged one-shot path. A continuable Activation directly owns its `AgentHandle` and never creates, wraps, or retains a `SubagentRun`; `SubagentRun.steer?()` is therefore absent. -`ctx.subagents.followup(authority, childId, content, { source, signal })` remains the sole continuation-message operation. `authority` is either `{ kind: 'parent', agent }` or `{ kind: 'user' }`; the parent variant is admitted only from an exact live Agent tool context, while only a trusted host adapter can supply user authority. `source` remains durable provenance and grants no authority. The model-facing `send_message` tool keeps only its stable `subagent_id` and `message` fields and always submits a follow-up turn. Both start and follow-up return the accepted `AgentMessageId`, and neither reports how the manager materialized the Activation. +`ctx.subagents.followup(authority, childId, content, { source, signal })` remains the sole continuation-message operation. `authority` is either `{ kind: 'parent', agent }` or `{ kind: 'user' }`; the parent variant is admitted only from an exact live Agent tool context, while only a trusted host adapter can supply user authority. `source` remains durable provenance and grants no authority. The model-facing `send_message` tool keeps only its stable `subagent_id` and `message` fields and always submits a follow-up turn. Both start and follow-up return the accepted `MessageId`, and neither reports how the manager materialized the Activation. -For start and follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance. After the operation returns its `AgentMessageId`, the manager owns the Activation independently; later caller cancellation does not cancel the accepted turn or dispose the child. +For start and follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance. After the operation returns its `MessageId`, the manager owns the Activation independently; later caller cancellation does not cancel the accepted turn or dispose the child. ### Durable Session and live Activation @@ -95,7 +95,7 @@ Routing depends only on Activation residency: | `waiting` | parent or user | wake the same Activation | | no Activation | parent or user | cold-resume a new Activation | -The continuation layer defines no separate delivery-route result. Successful `ctx.subagents.followup()` and `send_message` delivery returns the accepted `AgentMessageId`, while delivery failure throws. Existing `agent/inbox/enqueue`, `agent/inbox/dequeue`, and `agent/inbox/discard` events remain the message-lifecycle observations; adapters may render a generic acceptance but do not expose `started`, `queued`, `resumed`, or another subagent-specific route vocabulary. +The continuation layer defines no separate delivery-route result. Successful `ctx.subagents.followup()` and `send_message` delivery returns the accepted `MessageId`, while delivery failure throws. Existing `agent/inbox/enqueue`, `agent/inbox/dequeue`, and `agent/inbox/discard` events remain the message-lifecycle observations; adapters may render a generic acceptance but do not expose `started`, `queued`, `resumed`, or another subagent-specific route vocabulary. ### Child ownership @@ -171,22 +171,24 @@ The MVP adds no subagent steering operation, report tool, child-to-parent conten **Expose subagent steering in the MVP.** User steering can be a strict live-only host action, but parent steering needs current-turn controller state to protect a user-controlled turn. Queueing every first-version continuation avoids that state and its admission race. A later UI can add a distinct user-only action without changing follow-up ordering. -**Return a subagent-specific delivery route.** Labels such as `started`, `queued`, and `resumed` duplicate Activation and inbox state without giving the caller an independent result. Reusing `AgentMessageId` and the existing inbox events keeps delivery correlation on the Agent contract that owns it. +**Return a subagent-specific delivery route.** Labels such as `started`, `queued`, and `resumed` duplicate Activation and inbox state without giving the caller an independent result. Reusing `MessageId` and the existing inbox events keeps delivery correlation on the Agent contract that owns it. **Use a child reference count.** A count cannot identify which child still owns teardown work and permits duplicate decrement errors. An identity set retains cancellation and disposal obligations explicitly. -## Acceptance criteria +## Consequences + +The implementation pins these behaviors: - A continuable child has at most one live Activation and one Agent inbox; the continuation manager has no Activation FIFO or queued Activation state. - `SubagentProvider.prepareContinuable?()` returns only a detached `ContinuableCreateSpec`; configured continuable mode requires that capability, while `backgroundMode` remains an independent policy choice. -- The manager calls `ctx.agents.create()` through its private activation-owner scope, installs the returned `AgentHandle` and parent ownership, calls `Agent.followup(initialPrompt)`, and returns `{ childId, messageId }` when inbox acceptance yields the `AgentMessageId`, without waiting for turn start or a Session-log write. +- The manager calls `ctx.agents.create()` through its private activation-owner scope, installs the returned `AgentHandle` and parent ownership, calls `Agent.followup(initialPrompt)`, and returns `{ childId, messageId }` when inbox acceptance yields the `MessageId`, without waiting for turn start or a Session-log write. - Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership. - Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through the initial subagent provider; `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. - A continuable Activation directly owns `AgentHandle` and never creates, wraps, or retains `SubagentRun`; `SubagentProvider.start()` and `SubagentRun` remain one-shot-only, without `SubagentRun.steer?()`. - A user can cold-resume a persisted child without loading its historical parent. - `followup()` accepts only trusted parent or user authority; durable message provenance cannot authorize delivery. - Parent and user continuation messages always use `Agent.followup()` and share its inbox FIFO, including when one origin queues behind the other or the child already has an open turn. -- `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `AgentMessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result. +- `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `MessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result. - The MVP exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host and manager teardown retains child-first global cleanup. - The MVP exposes no subagent steering operation or current-turn controller state. - An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained. @@ -198,10 +200,10 @@ The MVP adds no subagent steering operation, report tool, child-to-parent conten - Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. - No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. - Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance failure, caller-signal ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. -- Unit coverage pins the residency-only routing table, single-inbox ordering, `AgentMessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools. +- Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools. - A keyless assembled-app snapshot covers parent delegation, mixed parent/user follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal. -## Risks +### Accepted costs Removing Tasks gives up generic background-work inspection, result collection, and exact Task cancellation. If those product features become requirements, they need a request ticket or inbox capability that does not reintroduce a second execution queue. diff --git a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md similarity index 93% rename from .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md rename to .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 11f59d8f1a..eb14ebcec9 100644 --- a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -1,6 +1,6 @@ # Agent Note(agent 决策记录):可继续的 subagent -Status: proposed +Status: implemented [English](2026-07-28-continuable-subagent-conversations.md) | 中文 @@ -16,7 +16,7 @@ Status: proposed 用户和 parent Agent 还需要在不改变当前轮次的前提下,向同一个在线 child 发送后续工作。将每条继续执行消息作为 follow-up 排队,可以让两类发送方遵循同一项排序规则。 -## 提案 +## 决策 一个可继续 subagent 拥有一个持久化会话,并且至多拥有一个进程内激活: @@ -34,7 +34,7 @@ persisted Session ### 物化与公开操作 -具名 subagent 提供方只参与准备初始创建规格,此时 `spawn` 与 `fork` 有所区别。其可选的 `prepareContinuable(request): Promise` 方法就是可继续创建能力。返回的规格只包含与 Agent 实例分离且由提供方决定的创建输入,例如可选的 parent 历史种子;它不包含 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作。管理器会预留 child 身份,解析持久化描述符和通用 Agent 配置,通过私有 activation-owner 作用域调用 `ctx.agents.create()`,将返回的 `AgentHandle` 安装到激活中,建立适用的可继续 parent 所有权,然后调用 `Agent.followup(initialPrompt)`。inbox 接受消息后会产生一个 `AgentMessageId`;`ctx.subagents.startContinuable()` 在此边界返回 `{ childId, messageId }`,不等待轮次开始,也不等待消息写入会话日志。 +具名 subagent 提供方只参与准备初始创建规格,此时 `spawn` 与 `fork` 有所区别。其可选的 `prepareContinuable(request): Promise` 方法就是可继续创建能力。返回的规格只包含与 Agent 实例分离且由提供方决定的创建输入,例如可选的 parent 历史种子;它不包含 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作。管理器会预留 child 身份,解析持久化描述符和通用 Agent 配置,通过私有 activation-owner 作用域调用 `ctx.agents.create()`,将返回的 `AgentHandle` 安装到激活中,建立适用的可继续 parent 所有权,然后调用 `Agent.followup(initialPrompt)`。inbox 接受消息后会产生一个 `MessageId`;`ctx.subagents.startContinuable()` 在此边界返回 `{ childId, messageId }`,不等待轮次开始,也不等待消息写入会话日志。 inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的情况下被拒绝。Agent 创建流程负责 handle 移交前的回滚;移交后,管理器会先 dispose 已创建的 handle、移除激活并回滚 parent `ownedChildren` 中的任何成员关系,再拒绝操作。 @@ -44,9 +44,9 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的 `SubagentProvider.start()` 和 `SubagentRun` 只保留在不变的 one-shot 路径上。可继续激活直接持有自身的 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;因此,`SubagentRun.steer?()` 不存在。 -`ctx.subagents.followup(authority, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。`authority` 可以是 `{ kind: 'parent', agent }` 或 `{ kind: 'user' }`;parent 变体仅能从确切的在线 Agent 工具上下文通过准入,只有可信宿主适配器才能提供用户权限。`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id` 和 `message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `AgentMessageId`,两者都不报告管理器如何物化激活。 +`ctx.subagents.followup(authority, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。`authority` 可以是 `{ kind: 'parent', agent }` 或 `{ kind: 'user' }`;parent 变体仅能从确切的在线 Agent 工具上下文通过准入,只有可信宿主适配器才能提供用户权限。`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id` 和 `message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `MessageId`,两者都不报告管理器如何物化激活。 -对于 start 和 follow-up,调用方 signal 只在 inbox 接受消息前持有查找、物化和准入。操作返回 `AgentMessageId` 后,管理器会独立持有该激活;调用方之后的取消不会取消已接受的轮次,也不会 dispose child。 +对于 start 和 follow-up,调用方 signal 只在 inbox 接受消息前持有查找、物化和准入。操作返回 `MessageId` 后,管理器会独立持有该激活;调用方之后的取消不会取消已接受的轮次,也不会 dispose child。 ### 持久化会话与在线激活 @@ -95,7 +95,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( | `waiting` | parent 或 user | 唤醒同一激活 | | 无激活 | parent 或 user | 冷恢复新激活 | -继续执行层不定义单独的投递路由结果。成功投递 `ctx.subagents.followup()` 或 `send_message` 时会返回已接受的 `AgentMessageId`,投递失败则会抛出异常。现有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 和 `agent/inbox/discard` 事件仍用于观测消息生命周期;适配器可以呈现通用的接受确认,但不暴露 `started`、`queued`、`resumed` 或其他 subagent 专属路由词汇。 +继续执行层不定义单独的投递路由结果。成功投递 `ctx.subagents.followup()` 或 `send_message` 时会返回已接受的 `MessageId`,投递失败则会抛出异常。现有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 和 `agent/inbox/discard` 事件仍用于观测消息生命周期;适配器可以呈现通用的接受确认,但不暴露 `started`、`queued`、`resumed` 或其他 subagent 专属路由词汇。 ### child 所有权 @@ -171,22 +171,24 @@ MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的 **在 MVP 中暴露 subagent steering。** 用户 steering 可以是严格且仅限在线使用的宿主操作,但 parent steering 需要当前轮次控制方状态,以保护由用户控制的轮次。首个版本将每条继续执行消息都排队,可以避免引入该状态及其准入竞争。后续 UI 可以新增一项仅限用户的独立操作,而不改变 follow-up 排序。 -**返回 subagent 专属的投递路由。** `started`、`queued` 和 `resumed` 等标签重复了激活与 inbox 状态,却没有给调用方提供独立结果。复用 `AgentMessageId` 和现有 inbox 事件,可以让投递关联继续由其所属的 Agent 契约承载。 +**返回 subagent 专属的投递路由。** `started`、`queued` 和 `resumed` 等标签重复了激活与 inbox 状态,却没有给调用方提供独立结果。复用 `MessageId` 和现有 inbox 事件,可以让投递关联继续由其所属的 Agent 契约承载。 **使用 child 引用计数。** 计数无法识别哪个 child 仍持有拆卸工作,也允许重复递减错误。身份集合会显式保留取消和 dispose 义务。 -## 验收标准 +## 影响 + +本实现固定了以下行为: - 可继续 child 至多拥有一个在线激活和一个 Agent inbox;继续执行管理器没有激活 FIFO 或 queued 激活状态。 - `SubagentProvider.prepareContinuable?()` 只返回分离式 `ContinuableCreateSpec`;配置为 continuable 时要求具备该能力,而 `backgroundMode` 仍是独立的策略选择。 -- 管理器通过私有 activation-owner 作用域调用 `ctx.agents.create()`,安装返回的 `AgentHandle` 并建立 parent 所有权,调用 `Agent.followup(initialPrompt)`,然后在 inbox 接受消息并产生 `AgentMessageId` 时返回 `{ childId, messageId }`,而不等待轮次开始或消息写入会话日志。 +- 管理器通过私有 activation-owner 作用域调用 `ctx.agents.create()`,安装返回的 `AgentHandle` 并建立 parent 所有权,调用 `Agent.followup(initialPrompt)`,然后在 inbox 接受消息并产生 `MessageId` 时返回 `{ childId, messageId }`,而不等待轮次开始或消息写入会话日志。 - 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系。 - 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过初始 subagent 提供方分发;`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。 - 可继续激活直接持有 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;`SubagentProvider.start()` 和 `SubagentRun` 只用于 one-shot,且没有 `SubagentRun.steer?()`。 - 用户可以在不加载历史 parent 的前提下冷恢复持久化 child。 - `followup()` 只接受可信 parent 或用户权限;持久化消息来源信息不能授权投递。 - Parent 和用户的继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括一种来源排在另一种来源之后,以及 child 已有开放轮次的情况。 -- `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `AgentMessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 +- `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 - MVP 不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 - MVP 不暴露 subagent steering 操作或当前轮次控制方状态。 - 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。 @@ -198,10 +200,10 @@ MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的 - 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 - 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 - 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前失败路径的完整回滚、接受前后两个阶段的调用方 signal 所有权,以及已接受但未写入日志的消息不会自动回放。 -- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `AgentMessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 +- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 - 一项无密钥整套应用快照覆盖 parent 委派、parent 与用户混合的 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 -## 风险 +### 已接受的代价 移除 Task 会放弃通用后台工作检查、结果收集和精确 Task 取消。如果这些产品功能成为需求,就需要不会重新引入第二条执行队列的请求 ticket 或 inbox 能力。 diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml index f28c1f6a8e..daf11549ed 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.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-26-merge-subagent-control-service.md -2026-07-26-merge-subagent-control-service.md: 84995446939d0f47e008bffb38083b1b6e0706de -2026-07-26-merge-subagent-control-service.zh.md: 7f82555159bfea9e00fa4cc2afdcf30382f3f776 +2026-07-26-merge-subagent-control-service.md: 67a26b6014efeb4f35911ccb90980d85f5e67557 +2026-07-26-merge-subagent-control-service.zh.md: 532a93e7c027ae18417e7aa4ae41faee21b5575a diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md index 8499544693..67a26b6014 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-26-merge-subagent-control-service.zh.md) -The public operation set is refined by [Intent-named subagent continuation operations](2026-07-27-intent-named-subagent-continuation-operations.md). +The public operation set is refined by [Intent-named subagent continuation operations](2026-07-27-intent-named-subagent-continuation-operations.md) and again by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which keeps the single merged service while removing provider `resume` dispatch and the Task-backed continuation lifecycle. ## Problem diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md index 7f82555159..532a93e7c0 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-26-merge-subagent-control-service.md) | 中文 -公开操作集合由[以意图命名的 subagent 继续执行操作](2026-07-27-intent-named-subagent-continuation-operations.md)进一步细化。 +公开操作集合由[以意图命名的 subagent 继续执行操作](2026-07-27-intent-named-subagent-continuation-operations.md)进一步细化,并由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)再次细化——后者保留这一个合并后的服务,同时移除提供方 `resume` 派发和基于 Task 的继续执行生命周期。 ## 问题 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml index 5623e559bc..a8b249fae0 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.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-27-intent-named-subagent-continuation-operations.md -2026-07-27-intent-named-subagent-continuation-operations.md: 1155e6b2fb89661021ebdbd6310902e74a500078 -2026-07-27-intent-named-subagent-continuation-operations.zh.md: 5f434cd8fbb171ef77a3b1f307029d6ade09f1d6 +2026-07-27-intent-named-subagent-continuation-operations.md: 9f29074add3517d0baf94516c56fa69085ef75c4 +2026-07-27-intent-named-subagent-continuation-operations.zh.md: a748af1a6cf44bc552b492d43314bf5a4e95338d diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md index 1155e6b2fb..9f29074add 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md) +The `followup` operation this record names is retained by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed return value with the accepted `MessageId`, its bare-`Agent` parameter with an explicit authority union, and provider `resume` dispatch with `prepareContinuable`. + ## Problem Merging continuable-child orchestration into `ctx.subagents` left provider dispatch and caller intent on the same public service. `resume(name, request)` accepted a descriptor, authorized parent, durable child id, and activation signal that only the internal continuation manager could resolve correctly. `sendMessage(...)` exposed transport wording rather than the `followup` intent already used by `Agent`, and its separate source and signal parameters widened an operation every caller had to use atomically. diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index 5f434cd8fb..a748af1a6c 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文 +本记录命名的 `followup` 操作由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)保留,但后者以已接受的 `MessageId` 替换其基于 Task 的返回值,以显式的 authority(授权)联合类型替换裸 `Agent` 参数,并以 `prepareContinuable` 替换提供方 `resume` 派发。 + ## 问题 将可继续 child 的编排合并到 `ctx.subagents` 后,提供方分发与调用方意图共存于同一个公开服务中。`resume(name, request)` 接受描述符、已鉴权的 parent、持久化 child id 与激活信号,而只有内部继续执行管理器才能正确解析这些数据。`sendMessage(...)` 暴露的是传输层措辞,而不是 `Agent` 已采用的 `followup` 意图;它还将来源与信号拆成独立参数,扩大了操作接口,而每个调用方都必须以原子方式同时使用二者。 diff --git a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml deleted file mode 100644 index 4ef20ef978..0000000000 --- a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/proposed/feature/2026-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: 3902fbc33004219f98d070d4b898de6b2c19d40d -2026-07-28-continuable-subagent-conversations.zh.md: 11f59d8f1a57e2d1bf375a3e1c1cd46043c60a3f From ae6976cbbd6d4ac0dba8c90b7880045aa00ca388 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:41:59 +0800 Subject: [PATCH 054/114] docs(subagent): regenerate catalogs for the activation-based seam Adds the new continuable types to the cordis-catalog type-link map and regenerates the cordis api/service/event catalogs, tool catalog, config catalog, and doc graphs. --- docs/config-catalog.md | 8 +-- docs/cordis-catalog/events.md | 8 +-- docs/cordis-catalog/services.md | 59 +++++++++++------ docs/event-producer-consumer.md | 10 +-- docs/tool-catalog.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 64 ++++++++++--------- scripts/gen-cordis-catalog.ts | 7 +- 7 files changed, 93 insertions(+), 65 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 33bd5777e6..29ba89cbef 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1582,7 +1582,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-fork/src/index.ts:30`](../packages/subagent/subagent-fork/src/index.ts) +Source: [`packages/subagent/subagent-fork/src/index.ts:31`](../packages/subagent/subagent-fork/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` @@ -1894,9 +1894,9 @@ export interface Config { */ enableRunInBackground?: boolean /** - * Background execution policy (default `one-shot`). `continuable` requires - * a provider with persisted resume support and returns both child and Task - * ids; follow-up adapters remain independently optional. + * Background execution policy (default `one-shot`). `continuable` requires a + * provider with the `prepareContinuable` capability and returns the durable + * child id; follow-up adapters remain independently optional. */ backgroundMode?: 'one-shot' | 'continuable' /** diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c7b0734585..877a6a44f2 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -794,7 +794,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:166`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -811,7 +811,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:114`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -826,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:146`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:120`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -848,7 +848,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:157`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:131`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a1ff16cb7e..11ba7fb59b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1949,30 +1949,53 @@ Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/stora ## `ctx.subagents` — `SubagentService` -Named provider registry with raw and Task-backed continuation operations. +Named provider registry with one-shot runs and continuable-child operations. ```ts cordis-catalog /** - * Start one durable continuable child through a Task-backed initial - * activation. - * @param spec - provider, Task label, and delegation request. - * @returns the stable child id and initial activation Task id. + * Establish one durable continuable child and deliver its initial prompt. + * Resolves when the child's inbox accepts that prompt, without waiting for the + * turn to start or for the message to reach the Session log; any earlier + * failure rejects with no ids and rolls back the child entirely. + * @param spec - provider, delegation request, and caller cancellation. + * @returns the durable child id and the accepted prompt's message id. + * @throws when continuation services are unavailable or materialization fails. */ -startContinuable(spec: ContinuableStartSpec): ContinuableStart +async startContinuable(spec: ContinuableStartSpec): Promise /** - * Follow up with a continuable child. A live child is steered and fulfillment - * confirms request admission; an idle child immediately returns a fresh Task - * whose descriptor lookup, authorization, and cold resume may later fail. - * @param parent - live direct parent authorizing the operation. + * Deliver one later message to a continuable child as its next FIFO turn. A + * resident child's Agent inbox accepts it directly (waking a `waiting` + * Activation), while an absent one is cold-resumed from its persisted + * Session. The Agent inbox is the only queue, so parent and user messages + * share one observable order. + * @param authority - trusted parent or user authority for this delivery. * @param childId - durable child session id. * @param content - user-role content to deliver. - * @param options - durable attribution and caller cancellation; aborting a - * live-delivery wait cancels the shared activation and awaits quiescence. - * @returns the existing steered Task or newly started Task. - * @throws when continuation services are unavailable or live delivery is not admitted. + * @param options - durable provenance and caller cancellation, which stops the + * operation only before inbox acceptance. + * @returns the accepted message's inbox id. + * @throws when continuation services are unavailable, authority is rejected, + * or the message was not admitted. */ -followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise +async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise + +/** + * Read one durable child's live residency state. + * @param childId - durable child session id. + * @returns its Activation state, or `undefined` when no Activation is live. + * @throws when continuation services are unavailable. + */ +activationState(childId: SessionId): ActivationState | undefined + +/** + * Close continuable admission synchronously, then dispose every live + * Activation forest child-first. A host calls this before disposing top-level + * agents so no descendant outlives the runtime that owns its teardown. + * @returns once every live Activation released its `AgentHandle`. + * @throws an aggregate error after all branches settle when any failed. + */ +async drainContinuable(): Promise /** * Register a provider under its name. Registration is effect-scoped and HMR @@ -2005,12 +2028,12 @@ list(): string[] * @param request - child prompt, parent, signal, and optional capabilities. * @returns the ready holder-owned run. */ -async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise +async start(name: string, request: SubagentStartRequest): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentFollowupResult](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) +Types: [ActivationState](../core-data-structures/subagent.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentAuthority](../core-data-structures/subagent.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:199`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:173`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 33f004e102..696f4f8f66 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | | `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:317`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui`](../packages/ui/tui) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:402`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:421`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | @@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:157`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:131`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 87e9a2a526..f3a3f87e3a 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1151,7 +1151,7 @@ The registered tool name is the load-time `toolName` config (default `subagent`) ### `send_message` -Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered. +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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered. ```json { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 6acad85629..0cf46786e3 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -882,15 +882,23 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'subagents', - summary: 'Named provider registry with raw and Task-backed continuation operations.', + summary: 'Named provider registry with one-shot runs and continuable-child operations.', methods: [ { - signature: 'startContinuable(spec: ContinuableStartSpec): ContinuableStart', - jsDoc: '/**\n * Start one durable continuable child through a Task-backed initial\n * activation.\n * @param spec - provider, Task label, and delegation request.\n * @returns the stable child id and initial activation Task id.\n */', + signature: 'async startContinuable(spec: ContinuableStartSpec): Promise', + jsDoc: '/**\n * Establish one durable continuable child and deliver its initial prompt.\n * Resolves when the child\'s inbox accepts that prompt, without waiting for the\n * turn to start or for the message to reach the Session log; any earlier\n * failure rejects with no ids and rolls back the child entirely.\n * @param spec - provider, delegation request, and caller cancellation.\n * @returns the durable child id and the accepted prompt\'s message id.\n * @throws when continuation services are unavailable or materialization fails.\n */', }, { - signature: 'followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', - jsDoc: '/**\n * Follow up with a continuable child. A live child is steered and fulfillment\n * confirms request admission; an idle child immediately returns a fresh Task\n * whose descriptor lookup, authorization, and cold resume may later fail.\n * @param parent - live direct parent authorizing the operation.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable attribution and caller cancellation; aborting a\n * live-delivery wait cancels the shared activation and awaits quiescence.\n * @returns the existing steered Task or newly started Task.\n * @throws when continuation services are unavailable or live delivery is not admitted.\n */', + signature: 'async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', + jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so parent and user messages\n * share one observable order.\n * @param authority - trusted parent or user authority for this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, authority is rejected,\n * or the message was not admitted.\n */', + }, + { + signature: 'activationState(childId: SessionId): ActivationState | undefined', + jsDoc: '/**\n * Read one durable child\'s live residency state.\n * @param childId - durable child session id.\n * @returns its Activation state, or `undefined` when no Activation is live.\n * @throws when continuation services are unavailable.\n */', + }, + { + signature: 'async drainContinuable(): Promise', + jsDoc: '/**\n * Close continuable admission synchronously, then dispose every live\n * Activation forest child-first. A host calls this before disposing top-level\n * agents so no descendant outlives the runtime that owns its teardown.\n * @returns once every live Activation released its `AgentHandle`.\n * @throws an aggregate error after all branches settle when any failed.\n */', }, { signature: 'registerProvider(provider: SubagentProvider): () => void', @@ -905,7 +913,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * List registered provider names in insertion order.\n * @returns the registered names.\n */', }, { - signature: 'async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise', + signature: 'async start(name: string, request: SubagentStartRequest): Promise', jsDoc: '/**\n * Establish a ready child on the named provider. Capability and semantic\n * checks run before delegation. Provider ownership lasts until its promise\n * fulfills; a rejection therefore has no run for the caller to dispose and\n * emits no run lifecycle events.\n * @param name - the provider to use.\n * @param request - child prompt, parent, signal, and optional capabilities.\n * @returns the ready holder-owned run.\n */', }, ], @@ -1567,6 +1575,10 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ + { + name: 'ActivationState', + declaration: 'export type ActivationState = \'running\' | \'waiting\' | \'settled\';', + }, { name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', @@ -1791,13 +1803,21 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContentBlockType', declaration: 'export type ContentBlockType = keyof ContentBlockMap;', }, + { + name: 'ContinuableCreateRequest', + declaration: 'export interface ContinuableCreateRequest {\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}', + }, + { + name: 'ContinuableCreateSpec', + declaration: 'export interface ContinuableCreateSpec {\n readonly seed?: readonly SessionEvent[];\n}', + }, { name: 'ContinuableStart', - declaration: 'export interface ContinuableStart {\n readonly childId: SessionId;\n readonly taskId: TaskId;\n}', + declaration: 'export interface ContinuableStart {\n readonly childId: SessionId;\n readonly messageId: MessageId;\n}', }, { name: 'ContinuableStartSpec', - declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly label: string;\n readonly request: Omit;\n}', + declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly request: Omit;\n readonly signal: AbortSignal;\n}', }, { name: 'CreateAgentOptions', @@ -2671,37 +2691,21 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'StreamChunk', declaration: '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};', }, + { + name: 'SubagentAuthority', + declaration: 'export type SubagentAuthority = {\n readonly kind: \'parent\';\n readonly agent: Agent;\n} | {\n readonly kind: \'user\';\n};', + }, { name: 'SubagentCapabilities', declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', }, - { - name: 'SubagentContinuation', - declaration: 'export interface SubagentContinuation {\n readonly sessionId: SessionId;\n readonly descriptor: SubagentDescriptorData;\n}', - }, - { - name: 'SubagentDescriptorData', - declaration: 'export interface SubagentDescriptorData {\n readonly version: number;\n readonly provider: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}', - }, { name: 'SubagentFollowupOptions', declaration: 'export interface SubagentFollowupOptions {\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n}', }, - { - name: 'SubagentFollowupResult', - declaration: 'export type SubagentFollowupResult = {\n readonly route: \'steered\';\n readonly taskId: TaskId;\n} | {\n readonly route: \'started\';\n readonly taskId: TaskId;\n};', - }, { name: 'SubagentProvider', - declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentProviderStartRequest): Promise;\n resume?(request: SubagentProviderResumeRequest): Promise;\n}', - }, - { - name: 'SubagentProviderResumeRequest', - declaration: 'export interface SubagentProviderResumeRequest {\n readonly sessionId: SessionId;\n readonly prompt: ContentBlock[];\n readonly source: MessageSource;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly descriptor: SubagentDescriptorData;\n}', - }, - { - name: 'SubagentProviderStartRequest', - declaration: 'export interface SubagentProviderStartRequest extends SubagentStartRequest {\n readonly continuation?: SubagentContinuation | undefined;\n}', + declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise;\n prepareContinuable?(request: ContinuableCreateRequest): Promise;\n}', }, { name: 'SubagentResult', @@ -2709,7 +2713,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n steer?(content: ContentBlock[], source: MessageSource): Promise;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n}', }, { name: 'SubagentStartRequest', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f7ab385afd..faaafc4c11 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -160,14 +160,15 @@ export const LINK_MAP: Readonly> = { SkillSummary: 'skills.md', SaveTextSpill: 'spill.md', SpillRef: 'spill.md', + ActivationState: 'subagent.md', + ContinuableCreateRequest: 'subagent.md', + ContinuableCreateSpec: 'subagent.md', ContinuableStart: 'subagent.md', ContinuableStartSpec: 'subagent.md', CoordinatorMessageSource: 'subagent.md', + SubagentAuthority: 'subagent.md', SubagentFollowupOptions: 'subagent.md', - SubagentFollowupResult: 'subagent.md', SubagentProvider: 'subagent.md', - SubagentProviderResumeRequest: 'subagent.md', - SubagentProviderStartRequest: 'subagent.md', SubagentRun: 'subagent.md', SubagentService: 'subagent.md', SubagentStartRequest: 'subagent.md', From 694b0783650d765449d82b37ca3900c904a11769 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:51:25 +0800 Subject: [PATCH 055/114] docs(subagent): update package READMEs for the activation lifecycle Rewrites the service API table, authority-versus-provenance contract, residency routing, and deferred-work list; scopes the in-process driver README to one-shot runs; and restates both model-facing tools' outputs, which no longer carry a task id. --- examples/acp-agent/tests/acp.snapshot.ts | 8 +- .../fixtures/subagent-durability-failure.ts | 37 +-- .../snapshots/subagent-continuable/input.json | 2 +- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 4 +- packages/subagent/README.zh.md | 4 +- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 16 +- .../subagent/subagent-inprocess/README.zh.md | 17 +- .../tests/structured.spec.ts | 24 +- .../tests/subagent-inprocess.spec.ts | 303 +----------------- .../tests/subagent-spawn.spec.ts | 25 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 57 ++-- packages/subagent/subagent/README.zh.md | 57 ++-- .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 12 +- .../tool-subagent-control/README.zh.md | 12 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 14 +- packages/subagent/tool-subagent/README.zh.md | 14 +- 21 files changed, 176 insertions(+), 450 deletions(-) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index c2e0328fab..853100cf5c 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -217,9 +217,11 @@ const SCENARIOS: Scenario[] = [ { name: 'subagent-fork', hasModelTurn: true, recorded: true }, { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, // Authored continuable-subagent transcript: a background delegation returns - // both the durable subagent id and its task id, a failed final durability - // confirmation reaches task_output with its diagnosis, and send_message to - // an unknown subagent id starts a follow-up task that settles unavailable. + // only the durable subagent id, two send_message calls queue as later FIFO + // turns on that same child (the parent is never woken with their output), + // send_message to an unknown subagent id fails without delivering, and the + // child's retained handle is disposed child-first at teardown despite a + // failed final durability confirmation. { name: 'subagent-continuable', hasModelTurn: true, diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 47f96c0b80..7829b3812b 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -1,45 +1,34 @@ import type { Context } from 'cordis' export const name = 'subagent-durability-failure' -export const inject = ['sessionPersistence', 'tasks'] +export const inject = ['sessionPersistence'] const UNKNOWN_CHILD_ID = '22222222-2222-4222-8222-222222222222' -const FOLLOW_UP_TASK_ID = 'subagent-2' /** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */ export function apply(ctx: Context): void { - const thirdStepEnded = Promise.withResolvers() - const followUpSettled = Promise.withResolvers() + const followupsAccepted = Promise.withResolvers() const persistence = ctx.sessionPersistence const load = persistence.load.bind(persistence) - // The unavailable-child lookup is real asynchronous I/O. Fence it between - // the authored step boundaries so runner speed cannot reorder the exact log. + // The unavailable-child lookup is real asynchronous I/O. Fence it behind both + // authored follow-ups so runner speed cannot reorder the exact log. persistence.load = async (id) => { - if (id === UNKNOWN_CHILD_ID) await thirdStepEnded.promise + if (id === UNKNOWN_CHILD_ID) await followupsAccepted.promise return load.call(persistence, id) } ctx.effect(() => () => { persistence.load = load - thirdStepEnded.resolve(undefined) - followUpSettled.resolve(undefined) + followupsAccepted.resolve(undefined) }, 'subagent snapshot ordering') - ctx.on('session/event', (session, event) => { - if (session.header.parentSession === undefined - && event.type === 'step/end' - && event.data.turn === 1 - && event.data.step === 3) { - thirdStepEnded.resolve(undefined) - } - }) - ctx.tasks.onTaskDone((snapshot) => { - if (snapshot.id === FOLLOW_UP_TASK_ID) followUpSettled.resolve(undefined) - }) - ctx.on('agent/step', async (agent, turn, step) => { - if (agent.session.header.parentSession === undefined && turn === 1 && step === 4) { - await followUpSettled.promise - } + // Both authored follow-ups reach the child inbox before the unknown-id lookup + // runs, so the queued FIFO order is what the transcript records. + let accepted = 0 + ctx.on('agent/inbox/enqueue', (agent) => { + if (agent.session.header.parentSession === undefined) return + accepted += 1 + if (accepted >= 3) followupsAccepted.resolve(undefined) }) const flushedTurnEnds = new WeakSet() diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/input.json b/examples/acp-agent/tests/snapshots/subagent-continuable/input.json index 7fd4a2c3e4..9566755044 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/input.json +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/input.json @@ -8,7 +8,7 @@ }, { "op": "prompt", - "text": "Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Collect its result with task_output using the task id from the acknowledgement and wait: true. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.'. 4. Collect the task it started with task_output and wait: true, and observe that it failed. 5. Reply with the single word DONE. Do not use the bash tool." + "text": "Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool." } ] } diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index bead24d34c..bbaa8070ac 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/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/subagent/README.md -README.md: a195ecbaeb24cb63af8cdd4ac872bb6a2fc97d46 -README.zh.md: b9965030a38b603f7c03d98d6b8021acbeb47fda +README.md: e6e83866e04185ccb1f25187f450ea0e0e549128 +README.zh.md: 9a7ad5c37ce7d09e4f9f4d21c49175506c024f9b diff --git a/packages/subagent/README.md b/packages/subagent/README.md index a195ecbaeb..e6e83866e0 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -6,7 +6,7 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | Package | Role | ctx key | |---|---|---| -| `subagent/` | Subagent service: named-provider registry, vocabulary, durable descriptor, and optional Task-backed continuation orchestration | `ctx.subagents` | +| `subagent/` | Subagent service: named-provider registry, vocabulary, durable descriptor, and continuable-child orchestration | `ctx.subagents` | | `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — | | `subagent-spawn/` | In-process backend: a fresh child agent, with cold resume | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) | @@ -15,6 +15,6 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-control/` | The optional, globally named `send_message` follow-up tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface and continuation orchestration live at `subagent/subagent/`. Raw `start` / `resume` dispatch stays independent of Tasks and persistence; an internal manager binds durable child sessions to disposable Task-backed activations only while the Task and Agent services are present, and resolves persistence only when a continuation operation runs. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. +The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index b9965030a3..9a7ad5c37c 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -6,7 +6,7 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | 包(package) | 角色 | ctx 键 | |---|---|---| -| `subagent/` | Subagent 服务:具名提供方注册表、词汇、持久化描述符与可选的由 Task 支撑的继续执行编排 | `ctx.subagents` | +| `subagent/` | Subagent 服务:具名提供方注册表、词汇、持久化描述符与可继续子 agent 编排 | `ctx.subagents` | | `subagent-inprocess/` | 共享进程内运行驱动器(不含提供方;每次运行使用一个清理 effect) | 无 | | `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) | | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) | @@ -15,6 +15,6 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | | `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools`) | -接口和继续执行编排位于 `subagent/subagent/`。原始 `start` / `resume` 分发仍与 Task 和持久化无关;只有在 Task 与 Agent 服务存在时,内部管理器才会把持久化子会话绑定到可 dispose、由 Task 支撑的 activation,并且只在继续执行操作运行时解析持久化服务。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 25b886b635..d190dfd0cf 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: 8d266e93021285e27e7819386a4de9c33492a796 -README.zh.md: 79450a32a7ecc3cf2a442524a2680614b3f28ed0 +README.md: 0495b7cae003a8c280689c4bfdd991e0f6950569 +README.zh.md: 2e512ffd281c6334db925c97b110934bbcc19eef diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 8d266e9302..0495b7cae0 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation and cold resume, optional child customization, result reading, cancellation, confirmed steering, and disposal—has one implementation here. +This package is the shared run driver for the two in-process providers' one-shot delegations. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation, optional child customization, result reading, cancellation, and disposal—has one implementation here. Continuable children never come through this driver: the continuation manager in `@deepseek-ai/dsh-subagent` composes and drives them directly, so this driver owns exactly one turn with one result. ## Start contract @@ -11,28 +11,20 @@ This package is the shared run driver for the two in-process providers. Spawn pa The driver follows this sequence: 1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header. -2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id. -3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the prepended one-shot `agent/prompt-submit` contribution. It appends the `subagent/descriptor` event before downstream prompt admission can block or throw; allowed admission opens the initial turn afterward, while the final required checkpoint persists the descriptor even when no turn opens. -4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result and require its participation result to be `true`. This final confirmation retries events retained after a failed turn checkpoint; if no listener participates or any listener fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the checkpoint failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior. -6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records. +2. Mint a fresh child session id and call `parent.ctx.agents.create` directly, passing the optional fork seed and required request signal into the factory's creation transaction. During the unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. +3. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. +4. Read the child's own last assistant message and latest message-triggered turn reason, excluding the fork seed prefix so a seeded parent message is never mistaken for child output. The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). -## Cold resume - -`resumeInProcessRun(request): Promise` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, final durability confirmation, abort handoff, and disposal follow the same contract as a continuable start. - ## Cancellation and ownership The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child. After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed. -Runs expose confirmed `steer`: a synchronous status check prevents the Agent-level idle fallback from starting an untracked turn, then the run submits through `Agent.steer()` and awaits that exact message's receipt. Fulfillment means a committed child request snapshot admitted the message; terminal turn policy, cancellation, disposal, or a settlement race rejects instead. A synchronously visible structured capture is rejected before submission because its terminal outcome is already authoritative. The run never falls through from rejected live delivery to a later queued turn or cold resume. - ## Spawn and fork inputs `InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 79450a32a7..2e512ffd28 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、确认式 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。 +本包是两个进程内提供方一次性委派共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建、可选的子 agent 定制、结果读取、取消和 dispose(资源释放),都在此共用同一套实现。可继续子 agent 绝不通过本驱动器:`@deepseek-ai/dsh-subagent` 中的继续执行管理器会直接组合并驱动它们,因此本驱动器只拥有一个轮次和一个结果。 ## 启动契约 @@ -11,28 +11,19 @@ 驱动器按以下顺序运行: 1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。 -2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 -3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会前置安装一次性的 `agent/prompt-submit` 贡献。它会在下游 prompt admission 能够阻止请求或抛出异常之前追加 `subagent/descriptor` 事件;admission 获准后才会开启初始轮次,即使没有轮次开启,最终的必需检查点仍会持久化该描述符。 -4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`,并要求其参与结果为 `true`。这次最终确认会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。 -6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 +2. 生成全新的子 agent 会话 id,并直接调用 `parent.ctx.agents.create`,把可选的 fork 初始内容和必需的请求信号传入工厂的创建事务。在未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 +3. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 +4. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除 fork 初始内容前缀,确保作为初始内容的父 agent 消息绝不会被误认为子 agent 输出。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 - -## 冷恢复 - -`resumeInProcessRun(request): Promise` 会在当前父级作用域下重建持久化的可继续子 agent:`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript(文本记录;fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、最终持久性确认、中止交接和 dispose 遵循与可继续启动相同的契约。 - ## 取消与所有权 必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。 兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。 -运行公开确认式 `steer`:同步状态检查会阻止 Agent 层的空闲 fallback 启动未跟踪轮次,随后运行通过 `Agent.steer()` 提交消息,并等待该准确消息的回执。兑现表示某个已提交的子 agent 请求 snapshot 接纳了消息;结束轮次的策略、取消、dispose(资源释放)或结算竞态会改为拒绝。已同步可见的结构化捕获会在提交前被拒绝,因为其终态结果已经具有权威性。实时投递被拒绝后,运行绝不会转而进入之后的排队轮次或冷恢复。 - ## Spawn 与 fork 输入 `InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`。spawn 省略该值。fork 提供平衡的已完成轮次前缀,并记录其长度,确保结果读取器不会把作为初始内容的父 agent 消息误认为子 agent 输出。 diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index d3396cd21a..ddfaf0a3d6 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { createUserMessage, CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm' +import { createUserMessage, CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -120,28 +120,6 @@ describe('in-process structured output', () => { await run.dispose() }) - it('confirmed steering rejects delivery once the structured result is captured', async () => { - const { ctx, parent } = await setup([ - toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), - ]) - // oxlint-disable-next-line prefer-const -- single assignment follows listener registration so pre-fulfillment events remain guardable. - let run: Awaited> | undefined - let delivery: Promise | undefined - ctx.on('session/event', (session, event) => { - if (session.header.parentSession === undefined || run === undefined - || event.type !== 'tool/result' || delivery !== undefined) return - delivery = run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' }) - void delivery?.catch(() => undefined) - }) - run = await ctx.subagents.start('spawn', structuredRequest(parent)) - const result = await run.result - if (delivery === undefined) throw new Error('structured result did not submit steering') - await expect(delivery) - .rejects.toThrow(/already reported its structured result; the message was not delivered/) - expect(result.structured).toEqual({ answer: 7 }) - await run.dispose() - }) - it('denies tool calls that FOLLOW the capture in the same response — terminal means terminal', async () => { // One model response carrying structured_output FIRST and a side-effecting // call after it: the continuation veto only fires at step end, so without diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 530720596f..2df336ff47 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -2,17 +2,16 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import InvariantService from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' -import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError } from '@deepseek-ai/dsh-subagent' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { resumeInProcessRun, startInProcessRun } from '../src/index.ts' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -39,22 +38,6 @@ function request(parent: Agent, signal = new AbortController().signal) { return { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal } } -function continuableRequest(parent: Agent) { - const sessionId = SessionId('continuable-child') - return { - ...request(parent), - continuation: { - sessionId, - descriptor: { - version: SUBAGENT_DESCRIPTOR_VERSION, - provider: 'spawn', - agentProvider: 'mock', - agentModel: 'mock', - }, - }, - } -} - function text(blocks: readonly { type: string; text?: string }[]): string { return blocks.filter(block => block.type === 'text').map(block => block.text).join('') } @@ -88,110 +71,7 @@ describe('startInProcessRun', () => { await run.dispose() }) - it('rejects a continuable child when no durability listener is registered', async () => { - const { parent } = await setup([textResponse('driver answer')]) - - const run = await startInProcessRun(continuableRequest(parent), {}) - const caught: unknown = await run.result.catch((error: unknown) => error) - - expect(caught).toBeInstanceOf(SubagentError) - const durabilityError = caught as SubagentError - expect(durabilityError.code).toBe('DURABILITY_FAILED') - expect(durabilityError.message).toContain('required durability checkpoint has no registered listener') - await run.dispose() - }) - - it('rejects when the durability listener disappears before final confirmation', async () => { - const { ctx, parent } = await setup([textResponse('driver answer')]) - let flushes = 0 - let detach = (): void => {} - detach = ctx.on('session/flush', (session) => { - if (session.header.parentSession === undefined) return - flushes++ - if (flushes === 1) detach() - }) - - const run = await startInProcessRun(continuableRequest(parent), {}) - const caught: unknown = await run.result.catch((error: unknown) => error) - - expect(caught).toBeInstanceOf(SubagentError) - const durabilityError = caught as SubagentError - expect(durabilityError.code).toBe('DURABILITY_FAILED') - expect(durabilityError.message).toContain('required durability checkpoint has no registered listener') - expect(flushes).toBe(1) - await run.dispose() - }) - - it('requires a final durability checkpoint for a continuable child', async () => { - const { ctx, parent } = await setup([textResponse('driver answer')]) - const failure = new Error('disk full') - let flushes = 0 - ctx.on('session/flush', (session) => { - if (session.header.parentSession === undefined) return - flushes++ - throw failure - }) - - const run = await startInProcessRun(continuableRequest(parent), {}) - const caught: unknown = await run.result.catch((error: unknown) => error) - expect(caught).toBeInstanceOf(SubagentError) - const durabilityError = caught as SubagentError - expect(durabilityError.code).toBe('DURABILITY_FAILED') - expect(durabilityError.cause).toBe(failure) - expect(durabilityError.message).toContain( - 'the latest child state was not confirmed persisted and may be unavailable or stale on resume: disk full', - ) - expect(flushes).toBe(2) - await run.dispose() - }) - - it('completes a continuable child when the final checkpoint retries a transient flush failure', async () => { - const { ctx, parent } = await setup([textResponse('driver answer')]) - let flushes = 0 - ctx.on('session/flush', (session) => { - if (session.header.parentSession === undefined) return - flushes++ - if (flushes === 1) throw new Error('temporary append failure') - }) - - const run = await startInProcessRun(continuableRequest(parent), {}) - await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) - expect(flushes).toBe(2) - await run.dispose() - }) - - it.each([ - { checkpoint: 'succeeds', failure: undefined }, - { checkpoint: 'fails', failure: new Error('disk full') }, - ])('lets cancellation own the result when the final durability checkpoint $checkpoint', async ({ failure }) => { - const { ctx, parent } = await setup([textResponse('driver answer')]) - const checkpointStarted = Promise.withResolvers() - const releaseCheckpoint = Promise.withResolvers() - let flushes = 0 - ctx.on('session/flush', async (session) => { - if (session.header.parentSession === undefined) return - flushes++ - if (flushes !== 2) return - checkpointStarted.resolve(undefined) - await releaseCheckpoint.promise - if (failure !== undefined) throw failure - }) - const controller = new AbortController() - - const run = await startInProcessRun({ - ...continuableRequest(parent), - signal: controller.signal, - }, {}) - await checkpointStarted.promise - controller.abort() - releaseCheckpoint.resolve(undefined) - - await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) - expect(flushes).toBe(2) - await run.dispose() - }) - - it('keeps foreground runs best-effort when their turn checkpoint fails', async () => { + it('does not add a final durability checkpoint to a foreground run', async () => { const { ctx, parent } = await setup([textResponse('driver answer')]) let flushes = 0 ctx.on('session/flush', (session) => { @@ -336,69 +216,18 @@ describe('startInProcessRun', () => { expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) - it('rejects an already-aborted resume before publication', async () => { - const { parent } = await setup([]) - const controller = new AbortController() - controller.abort('too late') - await expect(resumeInProcessRun({ - sessionId: SessionId('resumed-child'), - prompt: [{ type: 'text', text: 'continue' }], - source: { kind: 'user' }, - parent, - signal: controller.signal, - descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, - })).rejects.toThrow('aborted before child publication') - }) - - it('resumes without inventing undeclared agent model options', async () => { - const childId = SessionId('resumed-child') - let flushes = 0 - const child = { - id: childId, - options: {}, - session: new Session(childId), - status: 'idle', - acceptsNextStep: false, - ctx: { - sessions: { - flush: () => { - flushes++ - return Promise.resolve(true) - }, - }, - } as unknown as Context, - send(): void {}, - reserveTurnAdmission: () => undefined, - updateInbox: () => 'not-found', - followup(): void {}, - steer() { return { outcome: Promise.resolve({ status: 'rejected' as const }) } }, - inject(): void {}, - cancel(): void {}, - whenIdle: () => Promise.resolve(), - } as Agent - let resumedOptions: unknown - const parent = { - ctx: { - agents: { - resume: (options: { agentOptions: unknown }) => { - resumedOptions = options.agentOptions - return Promise.resolve({ agent: child, dispose: () => Promise.resolve() }) - }, - }, - }, - } as unknown as Agent - - const run = await resumeInProcessRun({ - sessionId: childId, - prompt: [{ type: 'text', text: 'continue' }], - source: { kind: 'plugin', plugin: 'test-coordinator' }, - parent, - signal: new AbortController().signal, - descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, - }) - expect(resumedOptions).toEqual({}) + it('stamps only the resolved depth when neither parent nor request declares a model route', async () => { + // The one-shot analogue of the deleted resume coverage ("resumes without + // inventing undeclared agent model options"): a bare parent with no request + // agentOptions yields a child whose options carry ONLY the stamped depth — + // no provider/model is fabricated, so the child's turn errors for want of a + // route rather than silently adopting one. + const { ctx } = await setup([]) + const parent = ctx.agentLoop.create(SessionId('routeless-parent'), {}) + const run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + expect(child.options).toEqual({ subagentDepth: 1 }) await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) - expect(flushes).toBe(1) await run.dispose() }) @@ -461,104 +290,4 @@ describe('startInProcessRun', () => { expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) - - it('confirmed steering rejects a settled child instead of queueing an untracked turn', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - const run = await startInProcessRun(request(parent), {}) - await run.result - await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' })) - .rejects.toThrow(/not running; the message was not delivered/) - const child = ctx.agents.get(run.id)! - expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) - await run.dispose() - }) - - it('confirmed steering rejects when a concluding tool prevents request admission', async () => { - const { ctx, parent } = await setup([toolCallResponse('c1', 'finalize', {})]) - const enteredTool = Promise.withResolvers() - const releaseTool = Promise.withResolvers() - ctx.tools.register(defineContentToolFixture({ - name: 'finalize', - description: 'Finish the child run.', - parameters: {}, - async execute(_args, exec) { - enteredTool.resolve(undefined) - await releaseTool.promise - exec.concludeTurn() - return [{ type: 'text', text: 'final' }] - }, - })) - const run = await startInProcessRun(request(parent), {}) - const child = ctx.agents.get(run.id)! - await enteredTool.promise - - const delivery = run.steer!([{ type: 'text', text: 'terminal race' }], { kind: 'user' }) - releaseTool.resolve(undefined) - await expect(delivery).rejects.toThrow(/stopped before steering admission; the message was not delivered/) - await run.result - expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) - await run.dispose() - }) - - it('confirmed steering fulfills only after the next request snapshot admits it', async () => { - const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')]) - const enteredStopping = Promise.withResolvers() - const releaseStopping = Promise.withResolvers() - let held = false - ctx.on('agent/turn-stopping', (agent) => { - if (agent.session.header.parentSession === undefined || held) return - held = true - enteredStopping.resolve(undefined) - return releaseStopping.promise - }) - - const run = await startInProcessRun(request(parent), {}) - const child = ctx.agents.get(run.id)! - await enteredStopping.promise - - let settled = false - const delivery = run.steer!([{ type: 'text', text: 'after the first step' }], { kind: 'user' }) - .then(() => { settled = true }) - await Promise.resolve() - expect(settled).toBe(false) - releaseStopping.resolve(undefined) - await delivery - - const result = await run.result - expect(adapter.requests).toHaveLength(2) - expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('after the first step') - expect((result.output[0] as { text?: string }).text).toBe('second') - const steering = child.session.events.find(event => event.type === 'steering/message') - expect(steering?.type === 'steering/message' && steering.data.message.source).toEqual({ kind: 'user' }) - await run.dispose() - }) - - it('carries steering from a non-terminal flush window into a tracked next turn', async () => { - const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')]) - const enteredFlush = Promise.withResolvers() - const releaseFlush = Promise.withResolvers() - let held = false - ctx.on('session/flush', (session) => { - if (session.header.parentSession === undefined || held) return - if (!session.events.some(event => event.type === 'turn/end')) return - held = true - enteredFlush.resolve(undefined) - return releaseFlush.promise - }) - - const run = await startInProcessRun(request(parent), {}) - const child = ctx.agents.get(run.id)! - await enteredFlush.promise - expect(child.status).toBe('running') - - const delivery = run.steer!([{ type: 'text', text: 'next tracked turn' }], { kind: 'user' }) - releaseFlush.resolve(undefined) - await delivery - const result = await run.result - expect(adapter.requests).toHaveLength(2) - expect(child.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) - expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) - expect((result.output[0] as { text?: string }).text).toBe('second') - await run.dispose() - }) }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index aeb6ef8cb7..acb102ea1b 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -235,19 +235,26 @@ describe('dsh-subagent-spawn', () => { expect(result.stopReason).toBe('aborted') }) - it('exposes confirmed steer (no run-level resume): a settled child rejects instead of queueing', async () => { + it('a one-shot run exposes neither steer nor resume; continuable creation is a provider capability', async () => { const { ctx, parent } = await setup([textResponse('x')]) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) - // A run represents one disposable activation: cold resume is a provider - // method, never a run method. + // A run is one disposable foreground activation: it has no steering and no + // cold resume. Continuable conversations never become a run — the + // continuation manager drives them through the provider's + // `prepareContinuable` capability instead. + expect('steer' in run).toBe(false) expect('resume' in run).toBe(false) - expect(typeof run.steer).toBe('function') await run.result - // Confirmed live-only contract: after the child settles, delivery fails loud - // rather than falling back to Agent.steer()'s idle queue (which would - // start an untracked turn). - await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' })) - .rejects.toThrow(/not running; the message was not delivered/) + // The spawn provider DOES advertise continuable creation, and — because a + // spawned child starts fresh — contributes no seed. + const provider = ctx.subagents.getProvider('spawn')! + expect(typeof provider.prepareContinuable).toBe('function') + const spec = await provider.prepareContinuable!({ + sessionId: SessionId('continuable-child'), + parent, + signal: new AbortController().signal, + }) + expect(spec.seed).toBeUndefined() await run.dispose() }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 0d8b499482..c906868e1d 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: a484352c486c067058bef806bad3bcd7623cf6cc -README.zh.md: 9a750d5dfa22c5df199cdb22e7de6207841d2803 +README.md: fc1eecb7d22c45377d5525ef0247bcf369a441a8 +README.zh.md: 762a027324bc40f159129c3cd4a438d2265fa32b diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index a484352c48..fc1eecb7d2 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -11,8 +11,8 @@ The family separates the stable interface from implementations and model-facing | Package | Role | |---|---| | `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, lifecycle events, and continuable-child orchestration. | -| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child, with cold resume. | -| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns, with cold resume. | +| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child; supports continuable children. | +| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. | | `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | | `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. | | `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. | @@ -21,35 +21,39 @@ Multiple providers may coexist under different names. This lets a deployment exp ## Service API -`SubagentService` has six main operations: +`SubagentService` has these operations: | Member | Meaning | |---|---| | `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. | | `getProvider(name)` | Return the provider, or `undefined` when absent. | | `list()` | Return provider names in insertion order. | -| `start(name, request)` | Validate an ordinary caller request, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuation state cannot enter through this operation. | -| `startContinuable(spec)` | Allocate a durable child id and register its initial Task-backed activation. Requires `ctx.tasks`, `ctx.agents`, session persistence, and a resumable provider. | -| `followup(parent, childId, content, { source, signal })` | Follow up with a durable child, matching `Agent.followup()` terminology. It steers the current activation or starts a new Task that cold-resumes the child. Aborting `signal` while live delivery awaits admission cancels the shared activation and rejects after quiescence. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. | +| `start(name, request)` | Validate an ordinary caller request, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. | +| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. | +| `followup(authority, childId, content, { source, signal })` | Deliver one later message to a continuable child as its next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `AgentMessageId`. A resident child's inbox accepts it directly (waking a `waiting` Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. | +| `activationState(childId)` | Read one durable child's live residency state (`running`, `waiting`, or `settled`), or `undefined` when no Activation is live. | +| `drainContinuable()` | Close continuable admission synchronously, then dispose every live Activation forest child-first. A host calls this before disposing top-level agents so no descendant outlives the runtime that owns its teardown. An aggregate error surfaces after every branch settles when any failed. | -`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. Only the internal continuation manager can add a stable child id and durable descriptor to the provider-facing `SubagentProviderStartRequest`; cold provider resume is likewise private dispatch after descriptor lookup and parent authorization. +`SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child. + +Authority for continuable operations comes from a trusted host interaction or an exact live Agent tool context: `SubagentAuthority` is `{ kind: 'parent', agent }` or `{ kind: 'user' }`. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority. Parent authority requires the exact live direct parent recorded in the child's durable header; user authority may continue any child, and may cold-resume it without loading its historical parent. Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries. ## Capabilities -Start-time features are advertised in `provider.capabilities` because the service must reject an unsupported request before child creation: +Start-time features are advertised in `provider.capabilities` because the service must reject an unsupported one-shot request before child creation: - `outputSchema` — enforce a structured final result. - `depthLimit` — enforce `maxDepth`. - `toolFilter` — apply the requested child tool restriction. - `persona` — apply a per-child persona. -Runtime features are optional methods whose presence is the capability check: `SubagentRun.steer?` fulfills only after a request snapshot in the active child admits the message and rejects rather than queueing an untracked turn, while `SubagentProvider.resume?` reconstructs a persisted continuable child. A run represents one disposable activation, so it deliberately has no cold-resume operation — a disposed run cannot be reconstructed after restart. +Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation. ## The durable descriptor -The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before any Task exists, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Malformed current-version payloads fail before provider dispatch; unsupported versions make the child non-resumable. The payload records the provider name, resolved child `agentOptions.provider`/`model`, and optional `persona`/`toolFilter` — explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. It omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. +The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before the child session exists, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Malformed current-version payloads fail before materialization; unsupported versions make the child non-resumable. The payload records the provider name, resolved child `agentOptions.provider`/`model`, and optional `persona`/`toolFilter` — explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. It omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (never captured for a continuable child). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. ## Delegation depth @@ -57,23 +61,35 @@ The seam owns the depth vocabulary shared by implementations and consumers: the `inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority. -## Ownership and lifecycle +## One-shot ownership and lifecycle -`provider.start(request): Promise` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. `provider.resume?(request)` shares the same contract for a resumed activation; only the continuation manager dispatches it. +`provider.start(request): Promise` is the ownership-transfer boundary and the only Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. -`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. For a continuable activation, a completed result also confirms that the provider made its final state durable; a failed required checkpoint rejects as infrastructure rather than publishing unconfirmed output. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. +`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. -A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the service-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. +A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. -The service emits `subagent/start` only after an ordinary start or privately dispatched provider resume has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names. +## Continuable children and Activations -Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. +A continuable child has one durable Session and at most one process-local **Activation** — one residency epoch for a reconstructed child Agent, not a request, result, cancellation, or Task boundary. The Agent inbox is the only turn queue, so the continuation manager owns residency while the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper. + +The public residency state has three values derived from Agent quiescence and the owned-child set, not a second state machine: `running` (an active admission, open turn, or waking inbox work), `waiting` (quiescent but still owning at least one undisposed child), and `settled` (quiescent with every owned child disposed, so the manager disposes the `AgentHandle` and removes the Activation). Every continuation message uses `Agent.followup()` and becomes one FIFO turn, so parent and user messages share one observable order with no steering of the current turn. Routing depends only on residency: `running` enqueues, `waiting` wakes the same Agent, and an absent Activation cold-resumes a new one. + +The manager reserves the child identity, resolves the durable descriptor, calls `ctx.agents.create()` (or `ctx.agents.resume()` for cold resume) through a private activation-owner scope, installs the returned `AgentHandle` in the Activation, establishes any continuable-parent ownership, and then submits the prompt. Cold resume never dispatches through a provider — the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input — so a user can cold-resume a persisted child without loading its historical parent. + +A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` or rejection reports `DURABILITY_FAILED` and still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting`. + +## Lifecycle events + +The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each continuable Activation's residency epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that never becomes resident emits only the terminal edge, because it has no start edge to pair. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. + +Run events are scoped to the delegating parent; a user-resumed continuable child has no delegating parent, so its lifecycle reaches unscoped listeners globally. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. Provider additions and removals also emit `subagent/provider-added` and `subagent/provider-removed`. Consumers such as the model-facing tool use those events because Cordis may load sibling plugins concurrently; configuration order does not prove registration order. ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool. Continuable background delegation calls `ctx.subagents.startContinuable()`, whose internal manager exists only while `ctx.tasks` and `ctx.agents` are available; session persistence is resolved per continuation operation. Collection and cancellation use the shared task tools. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. +The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. ## Model Experience @@ -85,5 +101,8 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **ACP children remain one-shot** — `AcpProvider.resume` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the provider method's presence. -- **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface waits for a concrete consumer. +- **ACP children remain one-shot** — an ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children. +- **No report delivery** — the MVP exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup; a completed child turn leaves its output in the durable child Session until a caller inspects that transcript or submits another authorized turn. +- **No subagent steering** — every continuation message opens a later FIFO turn, so a parent or user cannot redirect a turn already underway; the manager stores no current-turn controller state. +- **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol. +- **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 9a750d5dfa..762a027324 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -11,8 +11,8 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | 包 | 角色 | |---|---| | `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果/描述符类型、生命周期事件和可继续子 agent 编排。 | -| `@deepseek-ai/dsh-subagent-spawn` | 支持从持久化存储恢复的全新进程内子 agent。 | -| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容,并支持从持久化存储恢复的进程内子 agent。 | +| `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent;支持可继续子 agent。 | +| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 | | `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 | | `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 | | `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 | @@ -21,35 +21,39 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 服务 API -`SubagentService` 有六个主要操作: +`SubagentService` 具有以下操作: | 成员 | 含义 | |---|---| | `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 | | `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 | | `list()` | 按插入顺序返回提供方名称。 | -| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。此操作不允许传入继续执行状态。 | -| `startContinuable(spec)` | 分配持久化子 agent id,并注册其初始的由 Task 支撑的激活。要求 `ctx.tasks`、`ctx.agents`、会话持久化及可恢复的提供方。 | -| `followup(parent, childId, content, { source, signal })` | 对持久化子 agent 执行后续操作,术语与 `Agent.followup()` 一致。它会引导当前激活,或启动新 Task 从持久化存储恢复该子 agent。若在在线投递等待准入期间中止 `signal`,则会取消共享激活,并在其完全停稳后拒绝该调用。要求 `ctx.tasks` 和 `ctx.agents`;从持久化存储恢复还要求会话持久化。 | +| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 | +| `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | +| `followup(authority, childId, content, { source, signal })` | 将一条后续消息作为可继续子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `AgentMessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 `waiting` 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | +| `activationState(childId)` | 读取某个持久化子 agent 的实时驻留状态(`running`、`waiting` 或 `settled`);无实时 Activation 时返回 `undefined`。 | +| `drainContinuable()` | 同步关闭可继续准入,然后以子先于父的顺序 dispose 每一个实时 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 | -`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。只有内部继续执行管理器才能把稳定子 agent id 和持久化描述符添加到面向提供方的 `SubagentProviderStartRequest`;从持久化存储恢复时,向提供方的请求同样只会在查找描述符并授权父级后由内部管理器分发。 +`SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。 + +可继续操作的权限来自可信的 host 交互或准确的实时 Agent 工具上下文:`SubagentAuthority` 为 `{ kind: 'parent', agent }` 或 `{ kind: 'user' }`。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。父级权限要求准确匹配子 agent 持久化 header 中记录的实时直接父级;用户权限可以继续任何子 agent,并且可以在不加载其历史父级的情况下将其冷恢复。 同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 ## 能力 -启动时功能通过 `provider.capabilities` 声明,因为服务必须在创建子 agent 前拒绝不受支持的请求: +启动时功能通过 `provider.capabilities` 声明,因为服务必须在创建子 agent 前拒绝不受支持的一次性请求: - `outputSchema`:强制执行结构化最终结果; - `depthLimit`:强制执行 `maxDepth`; - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 -运行时功能以可选方法表示,方法是否存在就是功能检查:`SubagentRun.steer?` 只有在活跃子 agent 的请求快照准入消息后才会兑现;无法准入时会拒绝,而不会把消息排入未受跟踪的轮次。`SubagentProvider.resume?` 则会重建持久化的可继续子 agent。run 表示一次可 dispose 的激活,因此有意不提供从持久化存储恢复操作;进程重启后无法重建已 dispose 的 run。 +可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。 ## 持久化描述符 -该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在任何 Task 存在之前校验并分离声明的组合配置,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。格式错误的当前版本 payload 会在提供方分发前失败;不受支持的版本会使子 agent 无法恢复。payload 记录提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。它省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次激活的结果契约)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。 +该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在子 agent 会话存在之前校验并分离声明的组合配置,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。格式错误的当前版本 payload 会在物化前失败;不受支持的版本会使子 agent 无法恢复。payload 记录提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。它省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(可继续子 agent 从不捕获它)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。 ## 委派深度 @@ -57,23 +61,35 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。 -## 所有权与生命周期 +## 一次性所有权与生命周期 -`provider.start(request): Promise` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。`provider.resume?(request)` 对恢复后的激活采用相同契约;只有继续执行管理器会分发该请求。 +`provider.start(request): Promise` 是所有权转移边界,也是唯一由 Task 支撑的后台路径。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。对于可继续激活,完成的结果还会确认提供方已使其最终状态具备持久性;必需检查点失败会作为基础设施故障拒绝,而不会发布未经确认的输出。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。 +`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。 -本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。可继续启动会准确发布由服务分配的 `continuation.sessionId`。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。 +本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。 -服务只会在普通启动或内部向提供方分发的恢复操作兑现后发出 `subagent/start`。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 +## 可继续子 agent 与 Activation -运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 +可继续子 agent 拥有一个持久化 Session 和至多一个进程内 **Activation**——即被重建的子 agent 的一个驻留时段,而不是请求、结果、取消或 Task 边界。Agent inbox 是唯一的轮次队列,因此继续执行管理器负责驻留,而 Agent 循环负责所有轮次排序与执行。任何可继续路径都不会创建 Task 或中间的承载结果的包装器。 + +公共驻留状态有三个取值,由 Agent 停稳状态和所拥有子集推导,而非第二个状态机:`running`(存在活跃准入、进行中的轮次或唤醒型 inbox 工作)、`waiting`(已停稳但仍拥有至少一个未 dispose 的子 agent)、`settled`(已停稳且所有拥有的子 agent 都已 dispose,因此管理器 dispose `AgentHandle` 并移除 Activation)。每条后续消息都使用 `Agent.followup()` 并成为一个 FIFO 轮次,因此父级和用户消息共享同一个可观察顺序,且不会对当前轮次进行 steering(中途引导)。路由只取决于驻留状态:`running` 入队、`waiting` 唤醒同一 Agent,无 Activation 时则冷恢复一个新的。 + +管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发——持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入——因此用户可以在不加载历史父级的情况下冷恢复持久化子 agent。 + +受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose(子先于父)。顶层及其他非继续执行的 Agent 没有 Activation,处于该等待图之外。最终结算只把 `ctx.sessions.flush(child.session) === true` 视为持久性确认;`false` 或拒绝会报告 `DURABILITY_FAILED`,但仍会 dispose 句柄并释放所有权,因为保留失败的子 agent 会使其祖先永久停留在 `waiting`。 + +## 生命周期事件 + +服务会为每次一次性运行以及每个可继续 Activation 的驻留时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;从未驻留过的可继续时段只发出终止边,因为它没有可配对的开始边。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 + +运行事件受执行委派的父级作用域约束;用户恢复的可继续子 agent 没有执行委派的父级,因此其生命周期会全局到达无作用域的监听器。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 提供方新增和移除还会发出 `subagent/provider-added` 与 `subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task。可继续后台委派会调用 `ctx.subagents.startContinuable()`;只有 `ctx.tasks` 和 `ctx.agents` 可用时,其内部管理器才会存在,而会话持久化按每项继续执行操作解析。收集和取消使用共享 Task 工具。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 ## 模型体验 @@ -85,5 +101,8 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 已知限制与延期工作 -- **ACP 子 agent 仍为一次性**:`AcpProvider.resume` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过提供方方法是否存在来确定。 -- **生命周期事件只供观察**:影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。 +- **ACP 子 agent 仍为一次性**:ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。 +- **无 report 投递**:MVP 不提供 `report` 工具、子到父的内容投递或自动唤醒父级;已完成的子 agent 轮次会把其输出留在持久化子 agent Session 中,直到调用方查看该 transcript 或提交另一个经授权的轮次。 +- **无 subagent steering**:每条后续消息都会开启后续 FIFO 轮次,因此父级或用户无法重定向已经在进行的轮次;管理器不保存任何当前轮次控制器状态。 +- **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。 +- **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent,但丢失的消息不会自动重放。 diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index fc7ab47339..c717ced0a2 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/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/subagent/tool-subagent-control/README.md -README.md: 44fbd44b035ce283e404c491d9fa143a08b71127 -README.zh.md: 3fa1d1e543d1d390975c3aab16504954f283c2f4 +README.md: b62870217e0eaf57c1cd16204c703aada694d4f2 +README.zh.md: 24a4b7b69a2f95533e4f0b963156fce0aad46bf4 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 44fbd44b03..b62870217e 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.followup()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work. -The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the subagent service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool forwards its execution signal, so cancellation while live delivery awaits admission cancels the shared activation and settles only after the child reaches quiescence. The tool renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered. +The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It supplies exact live parent authority (`{ kind: 'parent', agent }`) from `exec.agent` and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. The child does not reply to the sender — its transcript by that id is the source of what it did. A delivery failure becomes an errored tool result stating the message was not delivered. ## Model Experience @@ -12,7 +12,7 @@ The tool performs no lifecycle routing. It attributes every follow-up as `{ kind #### What the model sees -The generated [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `subagent_id` and `message`, with delivery-or-continue semantics and the `task_output` collection path described. +The generated [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `subagent_id` and `message`, describing that the message becomes the subagent's next turn, that the subagent does not reply, and that a failure means the message was not delivered. #### Token effect @@ -26,11 +26,11 @@ Prefix-stable; the schema does not change at runtime. #### What the model sees -`message delivered to running task ` when the message joined the running activation, or `message started task continuing subagent ` when it started a cold-resume activation. Synchronous routing failures — an ownership conflict, a lost steering race, no live-delivery capability — are errored results whose message states the message was not delivered. An absent activation always reports `started`: lookup runs inside that Task, so an unknown, foreign, or descriptor-less child surfaces as the started Task settling `failed` (read through `task_output`), not as an errored `send_message` result. +`message queued as the next turn for subagent ` on acceptance; the canonical output carries the accepted `messageId`. A failure — an unauthorized or unknown child, a descriptor-less child that cannot be resumed, or admission rejected — is an errored result whose message states the message was not delivered. #### Token effect -One short acknowledgement per call; the child's response enters parent history only when collected through `task_output` (the completion notice is a status line, never the response). +One short acknowledgement per call; the child's response never returns through this tool, so its output enters parent history only if a caller reads the child transcript and relays it. #### KV Cache effect @@ -38,5 +38,5 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **A delivered message has no independent result** — its effect is reflected in the current Task's eventual result; only a started follow-up owns a fresh Task result. -- **Delivery can lose timing races** — a message racing task settlement, cancellation, or cleanup fails explicitly rather than falling through to cold resume; the model retries after the task settles. +- **A queued message has no independent result** — acceptance returns only its inbox `messageId`; the child's work on that turn lands in the durable child Session, read by its subagent id, and is neither delivered back nor collected through this tool. +- **No steering of the current turn** — every message opens a later FIFO turn, so a message sent while the child is working runs only after its current turn finishes and cannot redirect it. diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md index 3fa1d1e543..24a4b7b69a 100644 --- a/packages/subagent/tool-subagent-control/README.zh.md +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -4,7 +4,7 @@ 可选的全局具名 `send_message` 工具:`ctx.subagents.followup()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。 -本工具不执行生命周期路由。它将每条后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;subagent 服务会保留该来源,并在向运行中激活的现有 Task 在线投递消息与创建新 Task、从持久化存储恢复子 agent 之间做出选择。本工具会转发其执行信号,因此,若在在线投递等待准入期间取消,则会取消共享激活,并仅在子 agent 完全停稳后结算。本工具会渲染实际采用的路由及相关 Task id。投递失败会变为出错的工具结果,并明确说明消息未送达。 +本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它从 `exec.agent` 提供准确的实时父级权限(`{ kind: 'parent', agent }`),并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。子 agent 不会回复发送方——通过该 id 查看其 transcript 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。 ## 模型体验 @@ -12,7 +12,7 @@ #### 模型看到的内容 -已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id` 和 `message`,说明投递或继续执行的语义,以及通过 `task_output` 收集结果的路径。 +已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id` 和 `message`,说明消息会成为子 agent 的下一个轮次、子 agent 不会回复,以及失败即表示消息未送达。 #### Token 影响 @@ -26,11 +26,11 @@ #### 模型看到的内容 -消息加入运行中的激活时返回 `message delivered to running task `;消息启动一次从持久化存储恢复的激活时返回 `message started task continuing subagent `。同步路由失败,包括所有权冲突、steering(中途引导)竞态失败和缺少在线投递功能,都会成为出错的结果,其消息说明该消息未送达。不存在激活时始终报告 `started`:查找在该 Task 内运行,因此未知、属于其他 parent 或缺少描述符的子 agent 会表现为已启动的 Task 结算为 `failed`(通过 `task_output` 读取),而不是出错的 `send_message` 结果。 +接受时返回 `message queued as the next turn for subagent `;规范输出携带被接受的 `messageId`。失败,包括未授权或未知的子 agent、缺少描述符而无法恢复的子 agent,或准入被拒绝,都会成为出错的结果,其消息说明该消息未送达。 #### Token 影响 -每次调用产生一条简短确认消息;子 agent 的响应只会在通过 `task_output` 收集时进入父级历史(完成通知是状态行,绝不是响应)。 +每次调用产生一条简短确认消息;子 agent 的响应绝不会通过本工具返回,因此只有当调用方读取子 agent transcript 并转达时,其输出才会进入父级历史。 #### KV Cache 影响 @@ -38,5 +38,5 @@ ## 已知限制与延期工作 -- **已投递的消息没有独立结果**:其效果体现在当前 Task 的最终结果中;只有已启动的后续操作才拥有新的 Task 结果。 -- **投递可能在时序竞态中失败**:消息与 Task 结算、取消或清理发生竞态时会明确失败,不会改用从持久化存储恢复;模型会在 Task 结算后重试。 +- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 在该轮次的工作会落入持久化子 agent Session,按其 subagent id 读取,既不会回传,也不会通过本工具收集。 +- **不对当前轮次进行 steering**:每条消息都会开启后续 FIFO 轮次,因此在子 agent 工作时发送的消息只会在其当前轮次结束后运行,无法将其重定向。 diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 46daae94ea..e0f4c5e66d 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/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/subagent/tool-subagent/README.md -README.md: 9d60363602a9825730984700a7fe987d911e1cac -README.zh.md: 5964c38bd847c1c14cac9decdd913ca65c39e8f3 +README.md: db6a96e1417eba565ce649393a5937754279be0e +README.zh.md: c4c3175635d287d15ba4cd71c11b87818dcdc3e2 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 9d60363602..db6a96e141 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -10,7 +10,7 @@ Each plugin instance binds one `provider` to one `toolName`; the model receives A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output. -With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports resume. `continuable` requires `provider.resume`, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'background', taskId, subagentId }`, rendered as `started subagent as task `. The optional global `send_message` tool is not required to start continuable work. Either route uses a Task-owned signal, settles only after startup rollback or run disposal, and maps completed final text, abort → `killed`, and other failures → `failed`. Generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result, and the child does not report back — its transcript by that id is the source of its output, and the optional global `send_message` tool sends it more work. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). `toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). @@ -21,7 +21,7 @@ With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` r | `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). | | `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. | | `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. | -| `backgroundMode` | Background lifecycle policy, default `one-shot`. `continuable` requires provider resume support and returns a durable child id; it does not require the follow-up tool. | +| `backgroundMode` | Background lifecycle policy, default `one-shot`. `continuable` requires the provider's `prepareContinuable` capability and returns a durable child id; it does not require the follow-up tool. | | `agentOptions` | Provider-specific child `provider`, `model`, and positive `maxTokens`; the in-process provider treats explicit values as overrides of inherited parent options. | | `persona` | Per-child persona; requires provider `persona` capability. | | `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. | @@ -37,7 +37,7 @@ Foreground and background calls are exclusive. Children may share the parent's w #### What the model sees -The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`. +The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`, and continuable mode describes starting a background subagent that keeps its conversation and returns its subagent id, while one-shot mode describes a background task id collected with `task_output` and stopped with `task_kill`. #### Token effect @@ -61,15 +61,15 @@ The prompt and result remain in parent history until compaction; child working c Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. -### Background task result +### Background result #### What the model sees -Start returns exactly `started subagent as task ` in configured continuable mode, or `started background subagent task ` in configured one-shot mode. The generic task surface provides later status, final output, cancellation responses, and notices; an independently loaded `send_message` tool delivers follow-ups to a continuable child. +Start returns exactly `started subagent ` in configured continuable mode, or `started background subagent task ` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices. In continuable mode the child does not report back; an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its output. #### Token effect -The acknowledgement is retained; final output enters parent history only when collected or injected. +The acknowledgement is retained; a one-shot final output enters parent history only when collected or injected, while a continuable child's output never returns through this tool. #### KV Cache effect @@ -77,6 +77,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Background runs expose final output only** — intermediate child steps stay in the child session. +- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. - **Duplicate names across waiting instances are detected late** (`TODO(subagent-dup-toolname)`) — preventing provider-registration rollback requires a registry of intended names. - **Child policy is fixed per instance** — another model, persona, tool filter, or depth cap requires another distinctly named tool. diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 5964c38bd8..c4c3175635 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -10,7 +10,7 @@ 前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。 -设置 `run_in_background: true` 后,由 `backgroundMode` 选择路由。`one-shot` 会注册普通的父级所有 Task,并返回规范值 `{ kind: 'background', taskId }`;即使提供方支持恢复,也会渲染为 `started background subagent task `。`continuable` 要求 `provider.resume`,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'background', taskId, subagentId }`,渲染为 `started subagent as task `。启动可继续工作不要求加载可选的全局 `send_message` 工具。两条路由都使用 Task 所有的信号,只在启动回滚或 run dispose(资源释放)之后结算,并把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个普通的父级所有 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task `,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。可继续路由在 inbox 接受时兑现:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果,而且子 agent 不会回报——通过该 id 查看其 transcript 即是其输出来源,可选的全局 `send_message` 工具则向其发送更多工作。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -21,7 +21,7 @@ | `provider`(必填) | 提供方名称(`spawn`、`fork`、`acp` 等)。 | | `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 | | `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 | -| `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`continuable` 要求提供方支持恢复并返回持久化子 agent ID;它不要求加载后续消息工具。 | +| `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`continuable` 要求提供方具备 `prepareContinuable` 能力并返回持久化子 agent ID;它不要求加载后续消息工具。 | | `agentOptions` | 传给具体提供方的子 agent `provider`、`model` 和正整数 `maxTokens`;进程内提供方会用显式值覆盖继承的父级选项。 | | `persona` | 每个子 agent 独立的 persona;要求提供方具备 `persona` 能力。 | | `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 | @@ -37,7 +37,7 @@ #### 模型看到的内容 -当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述;启用后台模式会添加 `run_in_background`。 +当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述;启用后台模式会添加 `run_in_background`,可继续模式描述为启动一个保留其对话并返回子 agent id 的后台子 agent,而一次性模式描述为返回一个用 `task_output` 收集、用 `task_kill` 停止的后台任务 id。 #### Token 影响 @@ -61,15 +61,15 @@ 仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 -### 后台任务结果 +### 后台结果 #### 模型看到的内容 -在已配置的 continuable 模式下,启动时精确返回 `started subagent as task `;在已配置的 one-shot 模式下,则返回 `started background subagent task `。通用任务接口提供后续状态、最终输出、取消响应和通知;独立加载的 `send_message` 工具会把后续消息交付给可继续子 agent。 +在配置的可继续模式下,启动时精确返回 `started subagent `;在配置的一次性模式下,则返回 `started background subagent task `。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,子 agent 不会回报;独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其输出来源。 #### Token 影响 -确认消息会被保留;最终输出只在收集或注入时进入父级历史。 +确认消息会被保留;一次性最终输出只在收集或注入时进入父级历史,而可继续子 agent 的输出绝不会通过本工具返回。 #### KV Cache 影响 @@ -77,6 +77,6 @@ ## 已知限制与暂缓事项 -- **后台运行只公开最终输出**:子 agent 中间步骤留在子 agent 会话中。 +- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。 - **等待中实例的重复名称发现较晚**(`TODO(subagent-dup-toolname)`):若要阻止提供方注册回滚,需要一份预期名称注册表。 - **每个实例的子 agent 策略固定**:其他模型、persona、工具过滤器或深度上限都需要另一个名称不同的工具。 From 55f86367adf11a3291e09a53f95117879ef59bb6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:52:44 +0800 Subject: [PATCH 056/114] test(subagent): update in-process specs and pin both durability failure modes Deletes the in-process durability, resume, and steering tests whose premises the seam no longer has, keeping a one-shot analogue for agent-option resolution, and covers both a false and a rejecting final checkpoint in the manager spec. --- .../subagent/tests/continuation.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 8335d665e1..dbc4850b76 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -461,6 +461,23 @@ describe('continuable durability and teardown', () => { }) }) + it('reports DURABILITY_FAILED when the final checkpoint rejects', async () => { + const { ctx, parent } = await setup([textResponse('answer')]) + const warnings: string[] = [] + ctx.logger.warn = (message: string) => { warnings.push(message) } + // A listener that throws makes flush reject rather than return false. + ctx.on('session/flush', (session) => { + if (session.header.parentSession !== undefined) throw new Error('disk full') + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + // The handle is still disposed and ownership released, so nothing is pinned. + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { + expect(warnings.some(warning => warning.includes('durability checkpoint failed'))).toBe(true) + }) + }) + it('disposes every live Activation forest child-first on manager teardown', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([ From bc504195df6e17aa2fd4816eea04c0bb0cfcb3fd Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 14:01:13 +0800 Subject: [PATCH 057/114] test(subagent): close continuable coverage and drop unreachable guards Restores the one-shot settleRun coverage in its own file beside the helper, covers fork's seed contribution, the post-transfer rollback, the descriptor model route on cold resume, manager-unload drain, and a failing teardown branch. Removes three redundant checks the surrounding contracts already own: the duplicate-Activation and live-id pre-checks (AgentRegistry.enter is the authoritative collision boundary) and a rollback lifecycle edge that could never publish because the epoch had no start edge. --- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.zh.md | 269 ++++++++++-------- .../subagent-fork/tests/subagent-fork.spec.ts | 29 ++ .../subagent/subagent/src/continuation.ts | 55 +--- packages/subagent/subagent/src/index.ts | 7 +- .../subagent/tests/continuation.spec.ts | 182 +++++++++++- .../subagent/tests/run-settlement.spec.ts | 79 +++++ 7 files changed, 446 insertions(+), 179 deletions(-) create mode 100644 packages/subagent/subagent/tests/run-settlement.spec.ts diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index a535c7ab81..d655798990 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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/subagent.md -subagent.md: 8f24afec47a970711aae49cae6b3535b9f532e5f -subagent.zh.md: 50c5cb887ef814c074a85fc4fee9cd2fe85d685c +subagent.md: a58ecf13ba1f5df0e8e35c793eaf9aefc1e8a900 +subagent.zh.md: 541eace7fc6c8ae10ee22639680918e12d7762b3 diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 50c5cb887e..541eace7fc 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,24 +4,25 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`)。同一个 `ctx.subagents` 服务通过由 Task 支撑的内部管理器负责可继续子 agent 编排。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续后台 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) ## 两类能力,两种发现方式 -提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性则是可选方法;方法存在即为能力,TypeScript 的类型收窄即为发现机制:提供确认语义的在线 steering(中途引导)是 [`SubagentRun.steer`](#a-live-run-subagentrun),从持久化存储恢复是 [`SubagentProvider.resume`](#the-provider-seam-subagentprovider)。 +提供方通过一个静态描述符公布其**启动时**特性,服务会在单次 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。这些 flag 仅描述单次 [`start()`](#the-provider-seam-subagentprovider) 路径,即由提供方组合子 agent 的路径。**可继续**子 agent 由继续执行管理器自行组合,因此它们由唯一一个可选方法把关,方法存在即为能力,并以 TypeScript 的类型收窄作为发现机制:[`SubagentProvider.prepareContinuable`](#the-provider-seam-subagentprovider)。 ```ts type-equiv /** * Which START-TIME features a provider supports. Checked by the service before delegating to * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent - * degradation" rule). These static flags cover features needed before a run exists; runtime - * capabilities are optional methods whose presence is the capability — confirmed live steering - * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each - * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to - * `maxDepth`; the other names match. + * degradation" rule). These flags describe the ONE-SHOT + * {@link SubagentProvider.start} path, where the provider composes the child; + * continuable children are composed by the continuation manager itself and are + * gated by {@link SubagentProvider.prepareContinuable} instead. Each flag + * corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` + * to `maxDepth`; the other names match. */ interface SubagentCapabilities { readonly outputSchema: boolean @@ -31,16 +32,16 @@ interface SubagentCapabilities { } ``` -## 启动请求 +## 单次启动请求 工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。不支持的 schema 在启动时即失败;进程内后端将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。 ```ts type-equiv /** - * What a caller asks for when starting a subagent. The tool layer builds this - * from the model's `{ description, prompt }` plus its own config; the service - * validates {@link SubagentCapabilities} against the named provider and - * resolves a {@link SubagentProviderStartRequest} for dispatch. + * What a caller asks for when starting a ONE-SHOT subagent. The tool layer + * builds this from the model's `{ description, prompt }` plus its own config; + * the service validates {@link SubagentCapabilities} against the named provider + * before dispatching to {@link SubagentProvider.start}. */ interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -94,31 +95,41 @@ interface SubagentStartRequest { `signal` 是就绪前后唯一的取消通道。[subagent 组合控制 Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)规定 persona、live 全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 -提供方会接收单独的已解析请求类型。`SubagentService.start()` 的参数类型不包含继续执行状态;只有 `startContinuable()` 才会提供由服务分配的标识和描述符。 +提供方接收的正是此请求:单次委派不含由服务解析的继续执行状态,因为可继续子 agent 绝不会到达 `SubagentProvider.start()`。 -```ts type-equiv -/** - * Provider-facing start request after the service resolves optional - * continuation state. Ordinary callers use {@link SubagentStartRequest}; only - * the Task-backed continuation path can attach a stable child identity and - * durable descriptor. - */ -interface SubagentProviderStartRequest extends SubagentStartRequest { - /** - * Continuable-child state resolved by `ctx.subagents` before provider dispatch. - * The provider MUST publish exactly `sessionId` as the child identity - * instead of allocating one internally, and MUST append the snapshotted, - * model-hidden `subagent/descriptor` before the initial prompt is admitted. - * Requires {@link SubagentProvider.resume} (the - * continuation capability); the service rejects the request otherwise. - */ - readonly continuation?: SubagentContinuation | undefined -} +## 可继续子 agent 与激活 + +**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**——即被重建的子 Agent 的一段驻留纪元(residency epoch)。Activation 不是请求、结果、取消或 Task 边界:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、授权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。 + +```text +persisted Session + -> optional live Activation + -> one retained AgentHandle + -> Agent inbox as the only turn FIFO + -> zero or more owned child Activations ``` -## 可继续子 agent 与提供方恢复 +`SubagentService.startContinuable()` 会预留稳定的子 agent id,对版本化的 `subagent/descriptor` payload 建立快照,向指定提供方索取其分离的 `ContinuableCreateSpec`,通过私有的 activation-owner 作用域创建子 Agent,建立任何可继续父级的所有权,并提交初始 prompt。当收件箱(inbox)准入产出消息 id 时,它以 `{ childId, messageId }` resolve——无需等待轮次开始,也无需等待消息进入会话日志。在该准入之前的任何失败都会以两个 id 都不返回的方式 reject,并 dispose 任何已创建的 handle,回滚 Activation 与父级所有权。 -**可继续后台 subagent** 是一份持久化子 agent 会话,由一系列由 Task 支撑的激活组成。`SubagentService.startContinuable()` 会分配稳定的子 agent id、对版本化的 `subagent/descriptor` payload 建立快照,并通过面向提供方的启动请求传入二者;提供方会准确发布该 id,并在初始 prompt 获准前追加描述符。`SubagentService.followup()` 沿用 `Agent` 的意图动词:它会引导实时激活,或在加载并授权已停止的子 agent 后,仅在内部向提供方分发已解析的恢复请求。只有 `ctx.tasks` 和 `ctx.agents` 存在时,内部管理器才会负责描述符查找与 Task 关联;每项继续执行操作都要求持久化,而加载提供方注册表不要求持久化。`startContinuable()` 返回两个标识,`followup()` 则报告内容是对现有 Task 执行了 `steered`,还是 `started` 一个新 Task。每个发送方都通过一个选项对象提供 `MessageSource` 和取消信号;若在在线投递等待准入期间中止该信号,则会取消共享激活,并在其完全停稳后拒绝调用。可选的面向模型工具使用 `CoordinatorMessageSource` 及其工具执行信号,人工适配器则使用 `{ kind: 'user' }` 及其交互信号。 +`SubagentService.followup()` 是唯一的继续执行消息操作,其路由仅取决于 Activation 的驻留状态: + +| Activation 状态 | 发送方 | `followup` | +|---|---|---| +| `running` | parent 或 user | 在同一 Activation 中入队 | +| `waiting` | parent 或 user | 唤醒同一 Activation | +| 无 Activation | parent 或 user | 冷恢复一个新的 Activation | + +`running` 表示 Agent 拥有活跃的准入或轮次,或正在唤醒收件箱工作;`waiting` 表示它已停稳,但仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示已停稳且其拥有的每个子级都已 dispose,此时管理器会 dispose `AgentHandle` 并移除该 Activation。管理器根据 Agent 的完全停稳状态与其拥有的子级集合推导这些状态,而非维护第二套执行状态机;`activationState()` 报告当前值(无存活 Activation 时为 `undefined`)。 + +Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 `Agent.followup()` FIFO 轮次,因此 parent 与 user 消息共享同一个可观测顺序,且后续消息无法改变已在进行中的轮次。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 与 `agent/inbox/discard` 事件仍是消息生命周期的观测点,继续执行层不定义任何 subagent 专属的投递路由。 + +授权由受信任的宿主交互或一个确切的实时 Agent 工具上下文提供。仅当已认证的 Agent 是持久化子 agent 在 `SessionHeader.parentSession` 中记录的直接父级时,才会准入 parent 变体;只有受信任的宿主适配器才能提供 user 授权。`MessageSource` 与 `senderSessionId` 在准入之后是持久的来源凭据,不授予任何权限——可选的面向模型工具使用 `CoordinatorMessageSource`,宿主适配器则使用 `{ kind: 'user' }`。user 授权可以在不加载子 agent 历史父级的情况下冷恢复它。 + +对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 subagent 取消或 steering(中途引导)操作。 + +每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、最终的持久性检查点结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。 + +只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。`drainContinuable()` 是覆盖整个生命周期的停止路径:它同步关闭准入,随后以子级优先的方式 dispose 每一片存活的 Activation 森林,尽管个别分支失败仍会等待每个分支。持久化子会话不受该进程内拆卸的影响。 ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -131,76 +142,90 @@ interface CoordinatorMessageSource { ```ts type-equiv /** - * Options for following up with one continuable child. + * Who authorizes one continuable-subagent operation. Authority comes from a + * trusted host interaction or an exact live Agent tool context; durable + * {@link MessageSource} provenance never authorizes delivery. */ +type SubagentAuthority = + /** The exact live parent Agent whose tool context is making the call. */ + | { readonly kind: 'parent'; readonly agent: Agent } + /** A trusted host adapter acting for the human user. */ + | { readonly kind: 'user' } +``` + +```ts type-equiv +/** Options for following up with one continuable child. */ interface SubagentFollowupOptions { - /** Durable attribution retained on either live or resumed delivery. */ + /** Durable attribution retained on the delivered message; it grants no authority. */ readonly source: MessageSource - /** Caller cancellation for a live-delivery admission wait. */ + /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal } ``` ```ts type-equiv -/** - * How a continuable follow-up was routed: - * `steered` joined the running activation's existing Task without creating a - * Task of its own; `started` created a fresh Task that cold-resumes the - * durable child with the content. Failure is an exception, never a result — - * undelivered content throws. - */ -type SubagentFollowupResult = - | { readonly route: 'steered'; readonly taskId: TaskId } - | { readonly route: 'started'; readonly taskId: TaskId } -``` - -```ts type-equiv -/** - * The resolved continuable-child identity and durable composition record the - * service attaches before provider dispatch. - */ -interface SubagentContinuation { - /** Service-allocated stable child session id, published verbatim. */ - readonly sessionId: SessionId - /** Snapshotted descriptor persisted in the child log for cold resume. */ - readonly descriptor: SubagentDescriptorData +/** Identities returned once a continuable child accepted its initial prompt. */ +interface ContinuableStart { + /** The durable child session id, stable across activations. */ + readonly childId: SessionId + /** The accepted initial prompt's inbox message id. */ + readonly messageId: MessageId } ``` ```ts type-equiv /** - * Provider-facing request for reconstructing a persisted continuable child. - * The continuation manager loads the child log, folds and authorizes its - * descriptor, then privately dispatches this resolved request to - * {@link SubagentProvider.resume}. The provider reconstructs the declared - * composition under the live parent's scope and drives one turn with `prompt`. + * The public residency state of one continuable child, derived from Agent + * quiescence and the owned-child set rather than a second state machine: + * `running` — the Agent has an active admission or turn, or waking inbox work; + * `waiting` — the Agent is quiescent but still owns undisposed children; + * `settled` — quiescent with every owned child disposed, so the manager + * disposes the `AgentHandle` and removes the Activation. */ -interface SubagentProviderResumeRequest { - /** The persisted child session id to resume. */ +type ActivationState = 'running' | 'waiting' | 'settled' +``` + +提供方只参与准备初始创建 spec,`spawn` 与 `fork` 在此有所不同。其返回的 spec 只携带分离的、提供方专属的创建输入——目前是可选的父级历史种子——不含 Agent、`AgentHandle`、prompt 投递、结果、dispose 或 resume 操作。冷恢复根本不经由提供方分发:管理器折叠通用描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,并提交等待中的轮次。 + +```ts type-equiv +/** + * What the continuation manager asks a provider for while materializing one + * continuable child's FIRST activation. The manager has already reserved the + * durable child identity and owns every later operation, so this request + * carries only what distinguishes a fresh child from one seeded with parent + * history. + */ +interface ContinuableCreateRequest { + /** The reserved durable child session id, for provider diagnostics. */ readonly sessionId: SessionId - /** The follow-up message that starts the resumed activation's turn. */ - readonly prompt: ContentBlock[] - /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ - readonly source: MessageSource - /** - * The live parent agent — the direct parent recorded in the persisted child - * header. In-process backends reconstruct the child under this agent's - * currently loaded scope. - */ + /** The delegating parent agent whose history a seeding provider reads. */ readonly parent: Agent /** - * Activation-owned cancellation signal, created before descriptor lookup. - * Same pre/post-publication contract as {@link SubagentStartRequest.signal}: - * an abort before publication rejects after rollback quiescence, and an - * abort afterward cancels the published child turn. + * Caller cancellation, which owns preparation only until the manager accepts + * the initial prompt into the child's inbox. */ readonly signal: AbortSignal - /** The folded durable descriptor whose composition the provider reconstructs. */ - readonly descriptor: SubagentDescriptorData } ``` -描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照,包括提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;它绝不会对可通过合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则必须明确更改版本。描述符省略 `subagentDepth`(从持久化存储恢复时,以持久化 header 中的 `delegationDepth` 为单调下界)和 `outputSchema`(单次激活的结果契约,而非持久化组合配置)。`subagent/descriptor` 事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。 +```ts type-equiv +/** + * A provider's detached contribution to one continuable child's creation. This + * is DATA, never a capability: it carries no Agent, `AgentHandle`, prompt + * delivery, result, disposal, or resume operation, because the continuation + * manager owns the child's whole lifecycle after preparation. + */ +interface ContinuableCreateSpec { + /** + * Completed-turn prefix of the parent's log to seed the child session with, + * or absent for a fresh child. Same durable contract as + * `CreateAgentOptions.seed`: contiguous from seq 0, lossless JSON, balanced. + */ + readonly seed?: readonly SessionEvent[] +} +``` + +描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照——提供方名称、已解析的子 agent `agentOptions.provider`/`model`、可选的 `persona`/`toolFilter`——绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。它省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次结果契约,而非持久化组合配置)。继续执行管理器会在任何提供方提供的谱系之后、初始 prompt 获准之前,追加对模型隐藏的 `subagent/descriptor` 事件;`header.seedLength` 仍是 fork 谱系边界,因此描述符查找会读取子 agent 自身的后缀。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。 ## 终态结果:`SubagentResult` @@ -249,17 +274,18 @@ interface SubagentStopReasonMap { } ``` - +## 单次 run:`SubagentRun` -## 活跃 run:`SubagentRun` - -`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄;它表示一次可 dispose(资源释放)的激活,绝不是持久化子 agent handle。消费方 await `result` 并始终 dispose 该 run,直至其完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可继续结果为 completed 还表示提供方已确认本次激活的最终状态具备持久性;必需检查点失败则会 reject。可选且提供确认语义的 `steer` 方法通过自身的存在公布在线投递功能,并且只有在请求快照准入该消息后才会兑现。从持久化存储恢复属于提供方级操作:`SubagentProvider.resume` 会根据子 agent 的持久化会话重建一个新 run,因为进程内 run 在 dispose 或进程重启后就不再存在。 +`SubagentRun` 是消费方持有的、指向一个就绪单次子 agent 的句柄——一次可 dispose 的前台委派,只有一个结果,绝不是持久化子 agent handle。消费方 await `result` 并始终 dispose 该 run,直至完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有无法表示的基础设施故障才会 reject。run 没有 steering,也没有 resume:可继续对话根本没有 run,因为继续执行管理器直接持有它们的 `AgentHandle`,并通过子 agent 自己的收件箱为每个轮次排序。 ```ts type-equiv /** - * Child handle returned only after readiness. Consumers await {@link result} and must always - * {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime - * capability discovery; narrow their presence before calling. + * ONE-SHOT child handle returned only after readiness. Consumers await + * {@link result} and must always {@link dispose} to cancel remaining work and + * reach quiescence. A run is one disposable foreground delegation with one + * result; continuable conversations have no run — the continuation manager + * holds their `AgentHandle` directly and orders every turn through the child's + * own inbox. */ interface SubagentRun { /** @@ -278,10 +304,8 @@ interface SubagentRun { * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport * failure resolves with `stopReason: 'error'` so the consumer maps it to an - * `isError` tool result. For a continuable activation, a completed result - * also means the provider confirmed the activation's final state durable. - * Rejects on an infrastructure fault the seam cannot represent as a stop - * reason, including a failed required durability checkpoint. + * `isError` tool result. Rejects on an infrastructure fault the seam cannot + * represent as a stop reason. */ readonly result: Promise /** @@ -289,25 +313,16 @@ interface SubagentRun { * Idempotent. */ dispose(): Promise - /** - * OPTIONAL (confirmed live-steering capability): submit additional content - * to the active child and fulfill only after a committed request snapshot - * admits it. Rejects when terminal policy, cancellation, disposal, or a lost - * settlement race prevents admission; it never falls through to a queued - * untracked turn or cold resume. A run represents one disposable activation, - * so resuming a settled child goes through {@link SubagentProvider.resume}. - * `source` is retained on the admitted steering message without changing its - * user role in model history. - */ - steer?(content: ContentBlock[], source: MessageSource): Promise } ``` -本地 run 必须在 `start()` fulfill 前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切子 agent,并在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`。 +本地单次 run 必须在 `start()` fulfill 之前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切的子 agent,并在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`。 + + ## 提供方 seam:`SubagentProvider` -每个提供方是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型的措辞,而不暗示继承了工具、服务或权限。 +每个提供方都是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力,并拒绝在没有 `prepareContinuable` 的提供方上发起可继续 start。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型措辞,而不暗示继承了工具、服务或权限。 ```ts type-equiv /** @@ -327,33 +342,37 @@ interface SubagentProvider { */ readonly inheritsParentContext: boolean /** - * Establish a child and return its handle only after publication. The - * service has already validated that every requested start-time capability - * is supported, so an implementation may assume e.g. `request.maxDepth` is - * honorable when present. If setup fails or `request.signal` aborts before - * fulfillment, the provider owns and cleans all partial resources before this - * promise rejects. Ownership transfers to the caller only on fulfillment. + * Establish a ONE-SHOT child and return its handle only after publication. + * The service has already validated that every requested start-time + * capability is supported, so an implementation may assume e.g. + * `request.maxDepth` is honorable when present. If setup fails or + * `request.signal` aborts before fulfillment, the provider owns and cleans + * all partial resources before this promise rejects. Ownership transfers to + * the caller only on fulfillment. */ - start(request: SubagentProviderStartRequest): Promise + start(request: SubagentStartRequest): Promise /** - * OPTIONAL (continuation capability): reconstruct a persisted continuable - * child from its own transcript and declared descriptor, drive one - * follow-up turn, and return a fresh run. Method presence is the capability - * — the service rejects continuable starts and cold-resume dispatch on - * providers without it. Same publication contract as {@link start}: if - * reconstruction fails or `request.signal` aborts before fulfillment, the - * provider rolls its creation transaction back to quiescence before - * rejecting; after fulfillment the same signal cancels the published run. + * OPTIONAL (continuable-creation capability): contribute the detached + * creation inputs that distinguish this provider's continuable children — + * today only whether the child session is seeded with parent history. Method + * presence IS the capability: the service rejects continuable starts on + * providers without it, while a provider that has it may still serve + * ordinary one-shot delegations. + * + * This is the provider's ONLY participation in a continuable child. The + * continuation manager owns identity reservation, composition, Agent + * creation, prompt delivery, cold resume, ownership, and disposal, so a + * provider never sees the child's Agent, handle, turns, or teardown. */ - resume?(request: SubagentProviderResumeRequest): Promise + prepareContinuable?(request: ContinuableCreateRequest): Promise } ``` -提供方的 `start()` 仅在 run 就绪时 fulfill;提供方的 `resume()` 采用相同的发布与生命周期观察契约,但只有继续执行管理器会分发它。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 +提供方的 `start()` 仅在 run 就绪时 fulfill。服务铸造唯一的 `runId`,从提供方确切的 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。每个可继续 Activation 都会为其驻留纪元 emit 相同的仅观察事件对,因此一次冷恢复就是一段拥有自己 `runId` 的新纪元。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,且会隔离各自的 listener 异常。 ## 进程内后端:深度与种子 -spawn 和 fork 后端通过 `parent.ctx` 创建一个普通 agent,将取消信号传入核心创建流程,并通过 `AgentHandle` 进行 dispose。移除提供方会阻止新的 start,但不会撤销已接受的 run。每个子 agent 获得一个新的扁平作用域,而非继承父级注册。深度与 fork 种子注入复用既有的 agent 和会话词汇: +spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将取消信号传入核心创建流程,并通过 `AgentHandle` 进行 dispose;而可继续子 agent 则由继续执行管理器通过其自己的 activation-owner 作用域创建。移除提供方会阻止新的 start,但不会撤销已接受的 run。每个子 agent 获得一个新的扁平作用域,而非继承父级注册。深度与 fork 种子注入复用既有的 agent 和会话词汇: -- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 -- **Fork 种子注入**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `resume` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/support/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 +- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 +- **Fork 种子注入**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/support/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index f94ff5dbc6..924ed2df62 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -211,6 +211,35 @@ describe('dsh-subagent-fork', () => { expect(ctx.subagents.list()).toEqual([]) }) + it('contributes the completed-turn prefix as a continuable child\'s seed', async () => { + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child answer')]) + const provider = ctx.subagents.getProvider('fork')! + const signal = new AbortController().signal + + // Before any completed parent turn there is nothing to inherit, so the + // child starts fresh rather than carrying an empty seed. + const fresh = await provider.prepareContinuable!({ + sessionId: SessionId('continuable-fresh'), + parent, + signal, + }) + expect(fresh.seed).toBeUndefined() + + // Complete one parent turn, then the prefix is captured once at creation. + parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }) + await parent.whenIdle() + const seeded = await provider.prepareContinuable!({ + sessionId: SessionId('continuable-seeded'), + parent, + signal, + }) + expect(seeded.seed).toBeDefined() + const lastSeeded = seeded.seed!.at(-1) + // The seed ends at a completed turn, so it replays as a valid child log. + expect(lastSeeded?.type).toBe('turn/end') + expect(seeded.seed!.map(event => event.seq)).toEqual(seeded.seed!.map((_event, index) => index)) + }) + it('has the namespace-plugin export shape (no stray default)', () => { expect('default' in fork).toBe(false) expect(fork.name).toBe('subagent-fork') diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 80021ce205..e1a03489d7 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -113,10 +113,10 @@ export interface ActivationObserver { /** * Publish the terminal edge exactly once. An epoch that never became resident * emits nothing, because it has no start edge to pair. - * @param child - the child agent whose final output the edge reports, if any. + * @param child - the child agent whose final output the edge reports. * @param failure - the teardown or durability failure, or `undefined` on success. */ - settle(child: Agent | undefined, failure: unknown): void + settle(child: Agent, failure: unknown): void } /** Hooks the manager needs from the owning service. */ @@ -243,24 +243,6 @@ export class SubagentContinuationManager { }.bind(this), 'subagents.continuations()') } - /** - * Whether this manager still admits new materialization and delivery. Host - * teardown closes admission synchronously through {@link enterDraining}. - * @returns true once draining began. - */ - get isDraining(): boolean { - return this.draining - } - - /** - * Close admission synchronously: reject new creation, cold resume, and - * delivery so a host can drain the live Activation forest without racing new - * work. Idempotent. - */ - enterDraining(): void { - this.draining = true - } - /** * Read one durable child's live residency state. * @param childId - the durable child session id. @@ -384,7 +366,9 @@ export class SubagentContinuationManager { * @throws an aggregate error when any branch failed to release. */ async drain(): Promise { - this.enterDraining() + // Close admission synchronously before the first await, so no new creation, + // cold resume, or delivery can race the snapshot below. + this.draining = true // Snapshot roots after closing admission: a root is an Activation no live // Activation owns, so disposing roots recurses child-first into the forest. const owned = new Set() @@ -500,18 +484,10 @@ export class SubagentContinuationManager { signal: AbortSignal }): Promise { const { childId, provider, parent } = inputs - if (this.activations.has(childId)) { - throw new SubagentError( - `subagent "${childId}" already has a live activation; the message was not delivered`, - 'ACTIVATION_CONFLICT', - ) - } - if (this.ctx.agents.get(childId) !== undefined) { - throw new SubagentError( - `subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`, - 'OWNERSHIP_CONFLICT', - ) - } + // No id pre-check here: the child lock serializes each durable child, both + // callers reach this only after confirming no Activation exists, and + // `AgentRegistry.enter()` is the authoritative collision boundary for an id + // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) } const observer = this.host.observeActivation(provider, childId, parent) @@ -535,7 +511,7 @@ export class SubagentContinuationManager { } catch (error: unknown) { // Agent creation provides rollback before handle transfer, so nothing // outlives this rejection; report the epoch that never became resident. - observer.settle(undefined, error) + // No start edge was published, so this epoch has no lifecycle to close. throw error } @@ -558,16 +534,11 @@ export class SubagentContinuationManager { } catch (error: unknown) { // Roll the transfer back completely: the Activation leaves the map, the // parent's ownership membership is released, and the created handle is - // disposed before this rejection surfaces. + // disposed before this rejection surfaces. No lifecycle edge is published, + // because `observer.start()` below has not run for this epoch. this.activations.delete(childId) this.releaseOwnership(childId) - activation.disposal = (async () => { - try { - await handle.dispose() - } finally { - observer.settle(handle.agent, error) - } - })() + activation.disposal = handle.dispose() await activation.disposal.catch(() => undefined) throw error } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 3be59f2266..5f5f0d6fab 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -369,7 +369,7 @@ export class SubagentService extends Service { started = true this.emitLifecycle('subagent/start', identity, parent) }, - settle: (child: Agent | undefined, failure: unknown): void => { + settle: (child: Agent, failure: unknown): void => { // A failure before residency has no start edge to pair, and inventing // one would report a lifecycle the child never had. if (settled || !started) return @@ -462,9 +462,10 @@ export class SubagentService extends Service { /** * The child's last assistant message content, for one Activation's terminal * lifecycle edge. Absent when no assistant message reached the log. + * @param child - the settling child agent whose log is read. + * @returns its final assistant content, or `undefined` when it produced none. */ -function lastAssistantOutput(child: Agent | undefined): ContentBlock[] | undefined { - if (child === undefined) return undefined +function lastAssistantOutput(child: Agent): ContentBlock[] | undefined { const message = child.session.events.findLast( (event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message', ) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index dbc4850b76..2728415fde 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -630,13 +630,181 @@ describe('continuable public surface', () => { }) describe('continuable errors', () => { - it('rejects a second live Activation for the same durable child', async () => { - const { ctx, parent } = await setup([textResponse('unused')]) - // Occupy the id with an unmanaged live Agent. - const squatter = ctx.agentLoop.create(SessionId('squatted'), { provider: 'mock', model: 'mock' }) - await ctx.sessions.flush(squatter.session) - await expect(followup(ctx, { kind: 'user' }, SessionId('squatted'), message('hello'))) + it('rejects a duplicate Activation at the agent registry collision boundary', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + // Drop the Activation without disposing the Agent, leaving the id live but + // unmanaged. Materialization must not adopt it. + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map } + }).continuations + manager.activations.delete(started.childId) + + await expect(followup(ctx, { kind: 'user' }, started.childId, message('hello'))) .rejects.toThrow(SubagentError) - void parent + expect(ctx.agents.get(started.childId)).toBe(child) + hold.resolve() + }) + + it('rejects parent authority whose agent is no longer the live registry entry', async () => { + const { ctx, parent } = await setup([textResponse('first')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + // A stale parent reference: same id, not the exact live entry. + const stale = { ...parent, id: parent.id } as unknown as Agent + + await expect(followup(ctx, { kind: 'parent', agent: stale }, started.childId, message('stale'))) + .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + void child + }) + + it('rejects establishing a child under a parent whose disposal already began', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + + // Begin the parent Activation's teardown, then try to give it a child. + const drained = ctx.subagents.drainContinuable() + await expect(ctx.subagents.startContinuable(startSpec(child))) + .rejects.toMatchObject({ code: 'DRAINING' }) + hold.resolve() + await drained + }) + + it('reports a failing branch after every branch settles, without pinning the rest', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child done') }, + { chunks: textResponse('grandchild'), gate: hold.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() }) + // Make the grandchild's own handle disposal reject: scope teardown failure + // propagates, unlike a contained `agent/disposed` listener throw. + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map Promise } }> } + }).continuations + const branch = manager.activations.get(grandchild.childId)! + const realDispose = branch.handle.dispose.bind(branch.handle) + branch.handle.dispose = async () => { + await realDispose() + throw new Error('grandchild reap failed') + } + + const drained = ctx.subagents.drainContinuable() + hold.resolve() + await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) + // The other branch still released, and durable sessions survive. + expect(ctx.agents.get(started.childId)).toBeUndefined() + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.meta.id).toBe(started.childId) + }) + + it('rolls the transfer back when ownership registration fails after handle transfer', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('parent child'), gate: hold.promise }, + { chunks: textResponse('unused') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const outer = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(outer.childId) + expect(found).toBeDefined() + return found! + }) + // Begin the would-be parent's disposal, then race a grandchild into it. The + // handle transfers before ownership registration rejects, so the rollback + // must leave no Activation and no live Agent behind. + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map | undefined }> } + }).continuations + const before = new Set(ctx.agents.list().map(agent => agent.id)) + manager.activations.get(outer.childId)!.disposal = Promise.resolve() + + await expect(ctx.subagents.startContinuable(startSpec(child))) + .rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' }) + await vi.waitFor(() => { + expect(ctx.agents.list().map(agent => agent.id).filter(id => !before.has(id))).toEqual([]) + }) + hold.resolve() + }) + + it('reapplies the descriptor model route on cold resume', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed')]) + const started = await ctx.subagents.startContinuable({ + ...startSpec(parent), + request: { + prompt: message('routed work'), + parent, + agentOptions: { provider: 'mock', model: 'child-model' }, + }, + }) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.find(event => event.type === 'subagent/descriptor')?.data) + .toMatchObject({ agentProvider: 'mock', agentModel: 'child-model' }) + + // The resumed Activation runs on the declared route, not the parent's. + await followup(ctx, { kind: 'user' }, started.childId, message('again')) + await vi.waitFor(() => { + expect(ctx.agents.get(started.childId)?.options.model).toBe('child-model') + }) + await waitNoActivation(ctx, started.childId) + }) + + it('drains without continuation services as a no-op', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SubagentService) + // No `ctx.agents`, so no manager was ever bound and nothing was materialized. + await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined() + }) + + it('unloading the manager drains its live activations', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(AgentLoop, { agents: [] }) + const serviceFiber = await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() }) + + // Manager unload uses the same drain, so no child outlives its runtime. + const disposal = serviceFiber.dispose() + hold.resolve() + await disposal + expect(ctx.agents.get(started.childId)).toBeUndefined() }) }) diff --git a/packages/subagent/subagent/tests/run-settlement.spec.ts b/packages/subagent/subagent/tests/run-settlement.spec.ts new file mode 100644 index 0000000000..576eaa17b5 --- /dev/null +++ b/packages/subagent/subagent/tests/run-settlement.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { settleRun } from '../src/index.ts' + +describe('outcome mapping helpers', () => { + it.each([ + ['completed', { status: 'completed', output: 'partial' }], + ['aborted', { status: 'killed' }], + ['error', { status: 'failed', detail: 'error' }], + ['max-tokens', { status: 'failed', detail: 'max-tokens' }], + ['refusal', { status: 'failed', detail: 'refusal' }], + ['paused', { status: 'failed', detail: 'paused' }], + ] as const)('settleRun maps the %s stop reason onto its Task outcome', async (stopReason, expected) => { + const output = [{ type: 'text' as const, text: 'partial' }] + await expect(settleRun({ + id: SessionId('child'), + localAgent: undefined, + result: Promise.resolve({ output, stopReason: stopReason as never }), + dispose: () => Promise.resolve(), + })).resolves.toEqual(expected) + }) + + it('settleRun disposes the run before reporting, on both result paths', async () => { + const order: string[] = [] + const completed = await settleRun({ + id: SessionId('child-1'), + localAgent: undefined, + result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }), + dispose() { order.push('dispose'); return Promise.resolve() }, + }) + order.push('reported') + expect(completed).toEqual({ status: 'completed', output: 'ok' }) + expect(order).toEqual(['dispose', 'reported']) + + // An infrastructure rejection still disposes and reports failed. + let disposed = false + const failed = await settleRun({ + id: SessionId('child-2'), + localAgent: undefined, + result: Promise.reject(new Error('transport gone')), + dispose() { disposed = true; return Promise.resolve() }, + }) + expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' }) + expect(disposed).toBe(true) + + const durabilityMessage = 'subagent "child-3" durability checkpoint failed; latest state unavailable: disk full' + const durabilityFailed = await settleRun({ + id: SessionId('child-3'), + localAgent: undefined, + result: Promise.reject(new HarnessError( + durabilityMessage, + 'DURABILITY_FAILED', + { cause: new Error('disk full') }, + )), + dispose: () => Promise.resolve(), + }) + expect(durabilityFailed).toEqual({ status: 'failed', detail: durabilityMessage }) + + const disposeFailed = await settleRun({ + id: SessionId('child-4'), + localAgent: undefined, + result: Promise.resolve({ output: [], stopReason: 'completed' }), + dispose: () => Promise.reject(new Error('reap failed')), + }) + expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' }) + + const bothFailed = await settleRun({ + id: SessionId('child-5'), + localAgent: undefined, + result: Promise.reject(new Error('result failed')), + dispose: () => Promise.reject(new Error('reap failed')), + }) + expect(bothFailed).toEqual({ + status: 'failed', + detail: 'Error: result failed; dispose failed: Error: reap failed', + }) + }) +}) From 3911b7117ee9c55d1e0f5b7f747596b5ca143b91 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 14:09:24 +0800 Subject: [PATCH 058/114] test(subagent): reach full continuable coverage and simplify unreachable paths Adds coverage for the fork-seeded descriptor turn numbering, omitted and declared descriptor composition fields, a routeless cold resume, and the drain no-op. Splits materialize's create-versus-resume inputs so the impossible create-without-meta case disappears, drops the observer's unreachable pre-residency guard, and annotates the three remaining paths that only a non-deterministic send-versus-dispose race can reach. --- .../subagent/subagent/src/continuation.ts | 32 ++-- packages/subagent/subagent/src/index.ts | 10 +- .../subagent/tests/continuation.spec.ts | 164 ++++++++++++++---- .../subagent/subagent/tests/service.spec.ts | 6 + 4 files changed, 163 insertions(+), 49 deletions(-) diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index e1a03489d7..3ab4d342ef 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -111,8 +111,10 @@ export interface ActivationObserver { /** Publish the start edge once the epoch is resident. */ start(): void /** - * Publish the terminal edge exactly once. An epoch that never became resident - * emits nothing, because it has no start edge to pair. + * Publish the terminal edge exactly once, pairing this epoch's {@link start}. + * Called only for a resident epoch: a failure before residency publishes no + * edge at all, because inventing one would report a lifecycle the child never + * had. * @param child - the child agent whose final output the edge reports. * @param failure - the teardown or durability failure, or `undefined` on success. */ @@ -303,8 +305,7 @@ export class SubagentContinuationManager { childId, provider: spec.provider, parent, - seed, - meta: childSessionMeta(parent, childDepth, lineageSeedLength), + create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength) }, agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, @@ -344,16 +345,22 @@ export class SubagentContinuationManager { if (activation === undefined) return this.coldResume(authority, childId, content, options) // A delivery that arrives after the disposal transaction began must not // reach a handle being torn down; wait for release, then cold-resume. + /* v8 ignore next 3 -- the send-versus-dispose cutoff: reaching this arm needs a + * delivery to observe the transaction inside the same critical section that opened it, + * which no test can schedule deterministically. The behavior is covered end-to-end by + * "cold-resumes a delivery that lost the race with final disposal". */ if (activation.disposal !== undefined) { return activation.disposal.then(() => undefined, () => undefined) } await this.authorizeLive(authority, activation) return this.submit(activation, content, options.source, authority) }) + /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that + * race reaches the retry below, which then cold-resumes a new Activation. */ if (live !== undefined) return live - // The racing disposal completed; retry admission, which now cold-resumes. this.assertAdmitting() options.signal.throwIfAborted() + /* v8 ignore stop */ } } @@ -455,7 +462,6 @@ export class SubagentContinuationManager { childId, provider: descriptor.provider, parent: authority.kind === 'parent' ? authority.agent : undefined, - resume: true, agentOptions: { ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, @@ -476,9 +482,8 @@ export class SubagentContinuationManager { childId: SessionId provider: string parent: Agent | undefined - resume?: boolean - seed?: readonly SessionEvent[] - meta?: NonNullable + /** Creation inputs; absent for a cold resume, which loads the persisted session. */ + create?: { seed: readonly SessionEvent[]; meta: NonNullable } agentOptions: AgentOptions composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } signal: AbortSignal @@ -493,7 +498,8 @@ export class SubagentContinuationManager { const observer = this.host.observeActivation(provider, childId, parent) let handle: AgentHandle try { - handle = inputs.resume === true + const { create } = inputs + handle = create === undefined ? await this.ownerCtx.agents.resume({ resumeSessionId: childId, agentOptions: inputs.agentOptions, @@ -502,8 +508,8 @@ export class SubagentContinuationManager { }) : await this.ownerCtx.agents.create({ sessionId: childId, - ...inputs.meta !== undefined ? { meta: inputs.meta } : {}, - ...inputs.seed !== undefined ? { seed: inputs.seed } : {}, + meta: create.meta, + seed: create.seed, agentOptions: inputs.agentOptions, signal: inputs.signal, setup, @@ -539,6 +545,8 @@ export class SubagentContinuationManager { this.activations.delete(childId) this.releaseOwnership(childId) activation.disposal = handle.dispose() + /* v8 ignore next -- the created handle disposes cleanly on every rollback this + * transaction can reach; the catch only keeps a disposal fault from masking `error`. */ await activation.disposal.catch(() => undefined) throw error } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 5f5f0d6fab..0789113e7e 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -362,17 +362,17 @@ export class SubagentService extends Service { parent: Agent | undefined, ): ActivationObserver { const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true } - let started = false let settled = false return { start: (): void => { - started = true this.emitLifecycle('subagent/start', identity, parent) }, settle: (child: Agent, failure: unknown): void => { - // A failure before residency has no start edge to pair, and inventing - // one would report a lifecycle the child never had. - if (settled || !started) return + // Exactly one terminal edge per epoch: host shutdown, manager unload, + // child release, and normal settlement all converge on one disposal. + /* v8 ignore next -- the memoized disposal already collapses those callers into a + * single settle(); this guard keeps the edge single if that memoization ever changes. */ + if (settled) return settled = true const output = failure === undefined ? lastAssistantOutput(child) : undefined this.emitLifecycle('subagent/end', { diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 2728415fde..612c6763e8 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -13,6 +13,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService, { SubagentError, @@ -25,7 +26,7 @@ type Script = ConstructorParameters[0] /** One scripted response that may wait on a caller-released gate before streaming. */ interface GatedEntry { chunks: StreamChunk[] - gate?: Promise + gate?: Promise } /** Adapter whose entries can hold a model call open until the test releases it. */ @@ -221,6 +222,104 @@ describe('SubagentService.startContinuable', () => { expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) }) + it('omits undeclared composition fields from the descriptor', async () => { + const { ctx } = await setup([]) + // A routeless parent declares no provider/model, and this start declares no + // persona or tool filter, so the descriptor records only what exists. + const routeless = ctx.agentLoop.create(SessionId('routeless'), {}) + const started = await ctx.subagents.startContinuable(startSpec(routeless)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + const descriptor = child.session.events.find(event => event.type === 'subagent/descriptor') + + expect(descriptor?.data).toEqual({ + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + }) + await ctx.subagents.drainContinuable() + }) + + it('records a declared tool filter in the descriptor', async () => { + const { ctx } = await setup([]) + // Register one global tool so the filter names something real. + ctx.tools.register(defineTool({ + name: 'noop', + description: 'does nothing', + parameters: {}, + output: { + schema: { type: 'object', additionalProperties: false, properties: {} }, + render: () => [{ type: 'text', text: 'noop' }], + }, + execute: () => Promise.resolve({}), + })) + const routeless = ctx.agentLoop.create(SessionId('routeless-filtered'), {}) + const started = await ctx.subagents.startContinuable({ + ...startSpec(routeless), + request: { prompt: message('filtered work'), parent: routeless, toolFilter: { deny: ['noop'] } }, + }) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + + expect(child.session.events.find(event => event.type === 'subagent/descriptor')?.data) + .toEqual({ + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + toolFilter: { deny: ['noop'] }, + }) + await ctx.subagents.drainContinuable() + }) + + it('cold-resumes without inventing a model route the descriptor never declared', async () => { + const { ctx, root } = await setup([textResponse('first')]) + const routeless = ctx.agentLoop.create(SessionId('routeless-resume'), {}) + const started = await ctx.subagents.startContinuable(startSpec(routeless)) + await waitNoActivation(ctx, started.childId) + + const fresh = new Context() + await mountAgentLoopTestDependencies(fresh) + await fresh.plugin(JsonlSessionPersistence, { root: root! }) + await fresh.plugin(AgentLoop, { agents: [] }) + await fresh.plugin(SubagentService) + await fresh.plugin(SubagentSpawn, { providerName: 'spawn' }) + await followup(fresh, { kind: 'user' }, started.childId, message('resume routeless')) + + const resumed = await vi.waitFor(() => { + const found = fresh.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + expect(resumed.options.provider).toBeUndefined() + expect(resumed.options.model).toBeUndefined() + await fresh.subagents.drainContinuable() + }) + + it('numbers the descriptor turn after an inherited fork prefix', async () => { + const { ctx, parent } = await setup([ + textResponse('parent turn'), + textResponse('forked child'), + ]) + // Complete one parent turn so fork has a prefix to contribute. + parent.followup({ content: message('parent work'), source: { kind: 'user' } }) + await parent.whenIdle() + + const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + const descriptorTurn = loaded.events.find(event => event.type === 'turn/start' + && event.data.trigger.kind === 'subagent-descriptor') + // The seeded descriptor turn continues the inherited numbering rather than + // restarting at 1, so the replayed child log stays balanced. + expect(descriptorTurn?.type === 'turn/start' && descriptorTurn.data.turn).toBe(2) + expect(loaded.meta.seedLength).toBeGreaterThan(0) + }) + it('records the declared persona in the descriptor and reapplies it on cold resume', async () => { const { ctx, parent } = await setup([textResponse('scoped'), textResponse('resumed')]) const started = await ctx.subagents.startContinuable({ @@ -247,7 +346,7 @@ describe('SubagentService.startContinuable', () => { describe('SubagentService.followup residency routing', () => { it('enqueues in the same Activation while it is running, preserving one inbox FIFO', async () => { - const releaseFirst = Promise.withResolvers() + const releaseFirst = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('first'), gate: releaseFirst.promise }, { chunks: textResponse('second') }, @@ -266,7 +365,7 @@ describe('SubagentService.followup residency routing', () => { // Still the same Activation: no second child Agent was created. expect(ctx.agents.get(started.childId)).toBe(child) - releaseFirst.resolve() + releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) expect(userTexts(loaded.events)).toEqual(['child task', 'from parent', 'from user']) @@ -288,7 +387,7 @@ describe('SubagentService.followup residency routing', () => { }) it('wakes a waiting Activation instead of cold-resuming it', async () => { - const releaseGrandchild = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() const adapter = new GatedAdapter([ // The child delegates, then finishes its own turn while the grandchild runs. { chunks: textResponse('child done') }, @@ -315,7 +414,7 @@ describe('SubagentService.followup residency routing', () => { // Woken back to running on the SAME Activation. expect(ctx.agents.get(started.childId)).toBe(child) - releaseGrandchild.resolve() + releaseGrandchild.resolve(undefined) await waitNoActivation(ctx, grandchild.childId) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) @@ -380,7 +479,7 @@ describe('SubagentService.followup residency routing', () => { .rejects.toMatchObject({ code: 'NOT_RESUMABLE' }) }) - it('cold-resumes after losing a race with final disposal', async () => { + it('cold-resumes a delivery that lost the race with final disposal', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('after the race')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) const child = await vi.waitFor(() => { @@ -388,10 +487,11 @@ describe('SubagentService.followup residency routing', () => { expect(found).toBeDefined() return found! }) - // Send exactly while the Activation is settling: one side wins the cutoff, - // and a delivery that loses waits for release and cold-resumes. - await child.whenIdle() - const delivery = followup(ctx, { kind: 'user' }, started.childId, message('raced')) + // Deliver in the same tick the settlement watcher opens its transaction: + // exactly one side wins the cutoff. A delivery that loses awaits release and + // cold-resumes rather than reaching a handle being torn down. + const delivery = child.whenIdle().then(() => + followup(ctx, { kind: 'user' }, started.childId, message('raced'))) await expect(delivery).resolves.toBeTypeOf('string') await waitNoActivation(ctx, started.childId) @@ -402,7 +502,7 @@ describe('SubagentService.followup residency routing', () => { describe('continuable child ownership', () => { it('keeps a parent Activation waiting until its child completes disposal', async () => { - const releaseGrandchild = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('child done') }, { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, @@ -423,7 +523,7 @@ describe('continuable child ownership', () => { expect(ctx.agents.get(started.childId)).toBe(child) expect(ctx.agents.get(grandchild.childId)).toBeDefined() - releaseGrandchild.resolve() + releaseGrandchild.resolve(undefined) await waitNoActivation(ctx, grandchild.childId) await waitNoActivation(ctx, started.childId) }) @@ -440,7 +540,7 @@ describe('continuable child ownership', () => { describe('continuable durability and teardown', () => { it('reports DURABILITY_FAILED without leaking a waiting Activation', async () => { - const releaseResponse = Promise.withResolvers() + const releaseResponse = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('unconfirmed answer'), gate: releaseResponse.promise }, ]) @@ -452,7 +552,7 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) // Remove every durability listener, so the final checkpoint cannot confirm. await disposePersistence!() - releaseResponse.resolve() + releaseResponse.resolve(undefined) // The handle is still disposed and ownership released, so nothing is pinned. await waitNoActivation(ctx, started.childId) @@ -479,7 +579,7 @@ describe('continuable durability and teardown', () => { }) it('disposes every live Activation forest child-first on manager teardown', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('child done') }, { chunks: textResponse('grandchild'), gate: hold.promise }, @@ -498,7 +598,7 @@ describe('continuable durability and teardown', () => { ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) }) const drained = ctx.subagents.drainContinuable() // Let the held model call observe its cancellation so quiescence can settle. - hold.resolve() + hold.resolve(undefined) await drained // Child-first: the grandchild's disposal precedes its parent's. @@ -524,7 +624,7 @@ describe('continuable durability and teardown', () => { }) it('has no automatic replay for an accepted but unlogged message', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('first'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -533,7 +633,7 @@ describe('continuable durability and teardown', () => { await followup(ctx, { kind: 'user' }, started.childId, message('never logged')) const drained = ctx.subagents.drainContinuable() - hold.resolve() + hold.resolve(undefined) await drained await waitNoActivation(ctx, started.childId) @@ -548,8 +648,8 @@ describe('continuable lifecycle observation', () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) const starts: SubagentRunInfo[] = [] const ends: SubagentRunEndInfo[] = [] - ctx.on('subagent/start', info => { starts.push(info) }) - ctx.on('subagent/end', info => { ends.push(info) }) + ctx.on('subagent/start', (info) => { starts.push(info) }) + ctx.on('subagent/end', (info) => { ends.push(info) }) const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) @@ -608,7 +708,7 @@ describe('continuable public surface', () => { }) it('does not cancel an accepted turn when the caller signal aborts afterwards', async () => { - const releaseFirst = Promise.withResolvers() + const releaseFirst = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('first'), gate: releaseFirst.promise }, { chunks: textResponse('second') }, @@ -622,7 +722,7 @@ describe('continuable public surface', () => { // After acceptance the manager owns the Activation independently. controller.abort('caller gave up') - releaseFirst.resolve() + releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) expect(hasUserText(loaded.events, 'survives')).toBe(true) @@ -631,7 +731,7 @@ describe('continuable public surface', () => { describe('continuable errors', () => { it('rejects a duplicate Activation at the agent registry collision boundary', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -650,7 +750,7 @@ describe('continuable errors', () => { await expect(followup(ctx, { kind: 'user' }, started.childId, message('hello'))) .rejects.toThrow(SubagentError) expect(ctx.agents.get(started.childId)).toBe(child) - hold.resolve() + hold.resolve(undefined) }) it('rejects parent authority whose agent is no longer the live registry entry', async () => { @@ -670,7 +770,7 @@ describe('continuable errors', () => { }) it('rejects establishing a child under a parent whose disposal already began', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -684,12 +784,12 @@ describe('continuable errors', () => { const drained = ctx.subagents.drainContinuable() await expect(ctx.subagents.startContinuable(startSpec(child))) .rejects.toMatchObject({ code: 'DRAINING' }) - hold.resolve() + hold.resolve(undefined) await drained }) it('reports a failing branch after every branch settles, without pinning the rest', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('child done') }, { chunks: textResponse('grandchild'), gate: hold.promise }, @@ -716,7 +816,7 @@ describe('continuable errors', () => { } const drained = ctx.subagents.drainContinuable() - hold.resolve() + hold.resolve(undefined) await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) // The other branch still released, and durable sessions survive. expect(ctx.agents.get(started.childId)).toBeUndefined() @@ -725,7 +825,7 @@ describe('continuable errors', () => { }) it('rolls the transfer back when ownership registration fails after handle transfer', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('parent child'), gate: hold.promise }, { chunks: textResponse('unused') }, @@ -751,7 +851,7 @@ describe('continuable errors', () => { await vi.waitFor(() => { expect(ctx.agents.list().map(agent => agent.id).filter(id => !before.has(id))).toEqual([]) }) - hold.resolve() + hold.resolve(undefined) }) it('reapplies the descriptor model route on cold resume', async () => { @@ -786,7 +886,7 @@ describe('continuable errors', () => { }) it('unloading the manager drains its live activations', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) @@ -803,7 +903,7 @@ describe('continuable errors', () => { // Manager unload uses the same drain, so no child outlives its runtime. const disposal = serviceFiber.dispose() - hold.resolve() + hold.resolve(undefined) await disposal expect(ctx.agents.get(started.childId)).toBeUndefined() }) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 8b68e9554f..2260aaf119 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -119,6 +119,12 @@ describe('SubagentService', () => { expect('resume' in provider).toBe(false) }) + it('drains continuable activations as a no-op when no manager was bound', async () => { + const { subagents } = await service() + // Without `ctx.agents` no manager exists, so nothing was ever materialized. + await expect(subagents.drainContinuable()).resolves.toBeUndefined() + }) + it('rejects continuable operations when their runtime services are absent', async () => { const { subagents } = await service() await expect(subagents.startContinuable({ From c8fbc111db8f731de4a509f684247dbc971106a4 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 14:41:21 +0800 Subject: [PATCH 059/114] fix(acp): drain continuable subagents before disposing top-level agents A continuable Activation outlives the turn that started it and owns descendant teardown, so the bridge must drain that forest child-first before releasing the top-level agents whose runtime the descendants depend on. Also rewrites the authored continuable snapshot transcript for the Task-free tool surface; the scenario's keyless replay is still under diagnosis. --- .../fixtures/subagent-durability-failure.ts | 45 +++++-- .../subagent-continuable/session.1.jsonl | 57 ++++++--- .../subagent-continuable/session.jsonl | 115 +++++++++--------- packages/acp/acp/src/index.ts | 22 +++- 4 files changed, 154 insertions(+), 85 deletions(-) diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 7829b3812b..1f08074ca5 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -1,41 +1,72 @@ import type { Context } from 'cordis' +import { appendFileSync } from 'node:fs' export const name = 'subagent-durability-failure' -export const inject = ['sessionPersistence'] +export const inject = ['sessionPersistence', 'subagents'] const UNKNOWN_CHILD_ID = '22222222-2222-4222-8222-222222222222' /** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */ export function apply(ctx: Context): void { + const log = (...a: unknown[]): void => { + try { appendFileSync('/tmp/probe.log', '[PROBE] ' + a.map(String).join(' ') + '\n') } catch { /* ignore */ } + } const followupsAccepted = Promise.withResolvers() const persistence = ctx.sessionPersistence const load = persistence.load.bind(persistence) - // The unavailable-child lookup is real asynchronous I/O. Fence it behind both - // authored follow-ups so runner speed cannot reorder the exact log. persistence.load = async (id) => { - if (id === UNKNOWN_CHILD_ID) await followupsAccepted.promise + log('load', id) + if (id === UNKNOWN_CHILD_ID) { log('gating unknown-id load'); await followupsAccepted.promise; log('unknown-id load released') } return load.call(persistence, id) } + + // Patch followup to log routing. + const subagents = ctx.subagents as unknown as { followup: (...a: unknown[]) => Promise } + const origFollowup = subagents.followup.bind(subagents) + subagents.followup = async (...args: unknown[]) => { + log('followup childId=', args[1]) + return origFollowup(...args) + } + ctx.effect(() => () => { persistence.load = load followupsAccepted.resolve(undefined) }, 'subagent snapshot ordering') - // Both authored follow-ups reach the child inbox before the unknown-id lookup - // runs, so the queued FIFO order is what the transcript records. let accepted = 0 ctx.on('agent/inbox/enqueue', (agent) => { if (agent.session.header.parentSession === undefined) return accepted += 1 + log('child enqueue #', accepted, 'child=', agent.session.header.id) if (accepted >= 3) followupsAccepted.resolve(undefined) }) + ctx.on('subagent/start', (info: unknown) => { + log('subagent/start id=', (info as { id?: unknown }).id) + }) + + ctx.on('session/event', (session, event) => { + if (session.header.parentSession === undefined) return + if (event.type === 'turn/start') log('child turn/start turn=', event.data.turn, 'child=', session.header.id) + if (event.type === 'user/message') { + const c = event.data.content?.[0] + log('child user/message text=', c && c.type === 'text' ? c.text : '?', 'child=', session.header.id) + } + }) + + const flushes = new WeakMap() const flushedTurnEnds = new WeakSet() ctx.on('session/flush', (session) => { if (session.header.parentSession === undefined) return + const count = (flushes.get(session) ?? 0) + 1 + flushes.set(session, count) + log('child flush #', count, 'child=', session.header.id) if (session.events.at(-1)?.type !== 'turn/end') return - if (flushedTurnEnds.has(session)) throw new Error('snapshot disk full') + if (flushedTurnEnds.has(session)) { + log('THROW snapshot disk full') + throw new Error('snapshot disk full') + } flushedTurnEnds.add(session) }) } diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index 15e024fa07..67451e499b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -1,17 +1,40 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} -{"type":"subagent/descriptor","seq":0,"time":1784795691405,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} -{"type":"turn/start","seq":1,"time":1789000000000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":2,"time":1789000000001,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"5eabc0cb-6297-4988-92d9-554fb1cfdab7"},"surfaceOp":"append"} -{"type":"session/title","seq":3,"time":1789000000001,"data":{"title":"Reply with exactly the word","messageSeqs":[2],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":4,"time":1785517567401,"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":"57bfffb1-f18b-4e29-aaca-26ecaea51574"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785517567401,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785517567401,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785517567401,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":10,"time":1784795691405,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":11,"time":1785517567410,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":12,"time":1785517567410,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":13,"time":1785517567410,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"156cd267-c1e6-4030-b317-dc2936120f4a"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1785517567410,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":15,"time":1785517567411,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"/tmp/subagent-continuable","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"subagent-descriptor"}}} +{"type":"subagent/descriptor","seq":1,"time":1789000000002,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek","agentModel":"deepseek-v4-flash"}} +{"type":"turn/end","seq":2,"time":1789000000003,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}},"seq":3,"time":1789000000004} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":4,"time":1789000000005} +{"type":"step/start","data":{"turn":2,"step":1},"seq":5,"time":1789000000006} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"},"seq":6,"time":1789000000007} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":7,"time":1789000000008} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}},"seq":8,"time":1789000000009} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}},"seq":9,"time":1789000000010} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":10,"time":1789000000011} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":11,"time":1789000000012} +{"type":"assistant/message","data":{"turn":2,"step":1,"content":[{"type":"text","text":"CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":12,"time":1789000000013} +{"type":"step/end","data":{"turn":2,"step":1},"seq":13,"time":1789000000014} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}},"seq":14,"time":1789000000015} +{"type":"turn/start","data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}},"seq":15,"time":1789000000016} +{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append","seq":16,"time":1789000000017} +{"type":"step/start","data":{"turn":3,"step":1},"seq":17,"time":1789000000018} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"},"seq":18,"time":1789000000019} +{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":19,"time":1789000000020} +{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}},"seq":20,"time":1789000000021} +{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}},"seq":21,"time":1789000000022} +{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":22,"time":1789000000023} +{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":23,"time":1789000000024} +{"type":"assistant/message","data":{"turn":3,"step":1,"content":[{"type":"text","text":"SECOND_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":24,"time":1789000000025} +{"type":"step/end","data":{"turn":3,"step":1},"seq":25,"time":1789000000026} +{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}},"seq":26,"time":1789000000027} +{"type":"turn/start","data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}},"seq":27,"time":1789000000028} +{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append","seq":28,"time":1789000000029} +{"type":"step/start","data":{"turn":4,"step":1},"seq":29,"time":1789000000030} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"},"seq":30,"time":1789000000031} +{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":31,"time":1789000000032} +{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"text-delta","index":0,"text":"THIRD_OK"}},"seq":32,"time":1789000000033} +{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"THIRD_OK"}}},"seq":33,"time":1789000000034} +{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":34,"time":1789000000035} +{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":35,"time":1789000000036} +{"type":"assistant/message","data":{"turn":4,"step":1,"content":[{"type":"text","text":"THIRD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":36,"time":1789000000037} +{"type":"step/end","data":{"turn":4,"step":1},"seq":37,"time":1789000000038} +{"type":"turn/end","data":{"turn":4,"reason":{"kind":"error","step":1,"message":"snapshot disk full"}},"seq":38,"time":1789000000039} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index e34205b0f2..1b28826e21 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -1,57 +1,58 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1789000000000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1789000000001,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Collect its result with task_output using the task id from the acknowledgement and wait: true. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.'. 4. Collect the task it started with task_output and wait: true, and observe that it failed. 5. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"42a76bb1-818e-427e-8037-76b33c3a5c1f"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1789000000001,"data":{"title":"Follow these steps exactly, then","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785517567360,"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":"6dee8203-be1c-4287-86f3-db1ea0197c19"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785517567360,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785517567361,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785517567361,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1789000000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} -{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785517567370,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785517567370,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785517567370,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"631fd641-46e9-4e62-965a-2fd7a87e2720"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785517567370,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} -{"type":"tool/result","seq":14,"time":1785517567380,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333 as task subagent-1"}],"isError":false}],"role":"user","id":"28d3f6cb-8934-4dcc-9cf2-7db87b0df06a"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785517567380,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785517567387,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":1789000000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":18,"time":1789000000017,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_collect_1","name":"task_output","argumentsDelta":"{\"task_id\": \"subagent-1\", \"wait\": true}"}}} -{"type":"assistant/chunk","seq":19,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}}}} -{"type":"assistant/chunk","seq":20,"time":1785517567391,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":21,"time":1785517567391,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":22,"time":1785517567392,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fcea712-0e14-4f2d-909c-f7de70018053"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"tool/call","seq":23,"time":1785517567392,"data":{"turn":1,"step":2,"callId":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}} -{"type":"tool/result","seq":24,"time":1785517567419,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_collect_1"},"content":[{"type":"tool-result","toolCallId":"call_collect_1","content":[{"type":"text","text":"(no new output)\n[status: failed, subagent \"33333333-3333-4333-8333-333333333333\" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: snapshot disk full]"}],"isError":false}],"role":"user","id":"ae79571a-fa78-4de0-9614-a10b5223230c"}},"sourceEventSeqs":[23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":1785517567419,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":26,"time":1785517567425,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":27,"time":1789000000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":28,"time":1789000000027,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_follow_up","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}} -{"type":"assistant/chunk","seq":29,"time":1789000000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} -{"type":"assistant/chunk","seq":30,"time":1785517567430,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":31,"time":1785517567430,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":32,"time":1785517567430,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5e78130e-5ae7-4ec9-ad34-9e2400a23ef0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} -{"type":"tool/call","seq":33,"time":1785517567431,"data":{"turn":1,"step":3,"callId":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} -{"type":"tool/result","seq":34,"time":1785517567438,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_follow_up"},"content":[{"type":"tool-result","toolCallId":"call_follow_up","content":[{"type":"text","text":"message started task subagent-2 continuing subagent 22222222-2222-4222-8222-222222222222"}],"isError":false}],"role":"user","id":"6a7a5d22-1172-4a10-9230-ec12aed58e5e"}},"sourceEventSeqs":[33],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1785517567438,"data":{"turn":1,"step":3}} -{"type":"user/message","seq":36,"time":1785517567444,"data":{"content":[{"type":"text","text":"background task subagent-2 (subagent: Please continue.) finished [status: failed, SubagentError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]. Read its output with task_output."}],"source":{"kind":"plugin","plugin":"tool-tasks"},"role":"user","id":"32644e35-5ea1-4d29-8ef6-e09eb813781c"},"surfaceOp":"append"} -{"type":"step/start","seq":37,"time":1785517567444,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":38,"time":1789000000037,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":39,"time":1789000000038,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_collect_2","name":"task_output","argumentsDelta":"{\"task_id\": \"subagent-2\", \"wait\": true}"}}} -{"type":"assistant/chunk","seq":40,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}}}} -{"type":"assistant/chunk","seq":41,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":42,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":43,"time":1785517567453,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"31137fd0-a07c-4d5f-b847-6dbb33e86305"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} -{"type":"tool/call","seq":44,"time":1785517567454,"data":{"turn":1,"step":4,"callId":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}} -{"type":"tool/result","seq":45,"time":1785517567460,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_collect_2"},"content":[{"type":"tool-result","toolCallId":"call_collect_2","content":[{"type":"text","text":"(no new output)\n[status: failed, SubagentError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]"}],"isError":false}],"role":"user","id":"21807217-0a28-4369-868c-c2480398e883"}},"sourceEventSeqs":[44],"surfaceOp":"append"} -{"type":"step/end","seq":46,"time":1785517567460,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":47,"time":1785517567467,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":48,"time":1789000000047,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":49,"time":1789000000048,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":50,"time":1785517567471,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":51,"time":1785517567471,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":52,"time":1785517567471,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":53,"time":1785517567471,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fa9e88d9-d89c-4df7-85d5-0e4fd795ae69"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} -{"type":"step/end","seq":54,"time":1785517567472,"data":{"turn":1,"step":5}} -{"type":"turn/end","seq":55,"time":1785517567472,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"/tmp/subagent-continuable","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1789000000002,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Collect its result with task_output using the task id from the acknowledgement and wait: true. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.'. 4. Collect the task it started with task_output and wait: true, and observe that it failed. 5. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1789000000003,"data":{"title":"Follow these steps exactly, then","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1789000000004} +{"type":"request/header","seq":4,"time":1789000000005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":5,"time":1789000000006} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}},"seq":6,"time":1789000000007} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}},"seq":7,"time":1789000000008} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":8,"time":1789000000009} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":9,"time":1789000000010} +{"type":"assistant/message","data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":10,"time":1789000000011} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"},"seq":11,"time":1789000000012} +{"type":"tool/result","data":{"turn":1,"step":1,"callId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}]},"seq":12,"time":1789000000013} +{"type":"step/end","data":{"turn":1,"step":1},"seq":13,"time":1789000000014} +{"type":"step/start","data":{"turn":1,"step":2},"seq":14,"time":1789000000015} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":15,"time":1789000000016} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":16,"time":1789000000017} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_1","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}},"seq":17,"time":1789000000018} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}},"seq":18,"time":1789000000019} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":19,"time":1789000000020} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":20,"time":1789000000021} +{"type":"assistant/message","data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":21,"time":1789000000022} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"},"seq":22,"time":1789000000023} +{"type":"tool/result","data":{"turn":1,"step":2,"callId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}]},"seq":23,"time":1789000000024} +{"type":"step/end","data":{"turn":1,"step":2},"seq":24,"time":1789000000025} +{"type":"step/start","data":{"turn":1,"step":3},"seq":25,"time":1789000000026} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":26,"time":1789000000027} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":27,"time":1789000000028} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_2","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}},"seq":28,"time":1789000000029} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}},"seq":29,"time":1789000000030} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":30,"time":1789000000031} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":31,"time":1789000000032} +{"type":"assistant/message","data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":32,"time":1789000000033} +{"type":"tool/call","data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"},"seq":33,"time":1789000000034} +{"type":"tool/result","data":{"turn":1,"step":3,"callId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}]},"seq":34,"time":1789000000035} +{"type":"step/end","data":{"turn":1,"step":3},"seq":35,"time":1789000000036} +{"type":"step/start","data":{"turn":1,"step":4},"seq":36,"time":1789000000037} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":37,"time":1789000000038} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":38,"time":1789000000039} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_unknown","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}},"seq":39,"time":1789000000040} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}},"seq":40,"time":1789000000041} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":41,"time":1789000000042} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":42,"time":1789000000043} +{"type":"assistant/message","data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":43,"time":1789000000044} +{"type":"tool/call","data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"},"seq":44,"time":1789000000045} +{"type":"tool/result","data":{"turn":1,"step":4,"callId":"call_followup_unknown","content":[{"type":"text","text":"subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true},"seq":45,"time":1789000000046} +{"type":"step/end","data":{"turn":1,"step":4},"seq":46,"time":1789000000047} +{"type":"step/start","data":{"turn":1,"step":5},"seq":47,"time":1789000000048} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":48,"time":1789000000049} +{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":49,"time":1789000000050} +{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}},"seq":50,"time":1789000000051} +{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},"seq":51,"time":1789000000052} +{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":52,"time":1789000000053} +{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":53,"time":1789000000054} +{"type":"assistant/message","data":{"turn":1,"step":5,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":54,"time":1789000000055} +{"type":"step/end","data":{"turn":1,"step":5},"seq":55,"time":1789000000056} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":56,"time":1789000000057} diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index f3dd59679a..d76a184f5e 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -326,10 +326,24 @@ export function apply(ctx: Context, config: AcpConfig): void { closed = true const records = [...sessions.values()] sessions.clear() - quiescing = Promise.all(records.map(async (record) => { - settlePrompt(record, 'cancelled') - await record.dispose() - })).then(() => {}) + quiescing = (async () => { + // Continuable subagents outlive the turn that started them, and their + // Activations own descendant teardown. Drain that forest child-first + // BEFORE disposing the top-level agents, so no descendant is left holding + // a runtime its owner already released. + const subagents = ctx.get('subagents') + if (subagents !== undefined) { + try { + await subagents.drainContinuable() + } catch (error: unknown) { + logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`) + } + } + await Promise.all(records.map(async (record) => { + settlePrompt(record, 'cancelled') + await record.dispose() + })) + })() return quiescing } From 4435616a04c397a786aa8e177cebea098e6c2914 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 15:07:19 +0800 Subject: [PATCH 060/114] test(acp-agent): rewrite the continuable snapshot for the Task-free surface The authored transcript drove task_output, which no longer exists for a continuable child and is not registered in this config, so the scenario hung. It now demonstrates the RFC criteria directly: a delegation returning only the durable subagent id, two send_message follow-ups queueing as later FIFO turns on one inbox, an unknown id failing without delivery, and child-first disposal despite a failed final durability checkpoint. The snapshot-only overlay remaps the transcript's placeholder child id onto the randomly minted live child, since the scripted model cannot know that id. Also drops probe logging accidentally committed in cf0138258. --- .../fixtures/subagent-durability-failure.ts | 87 ++++++++------- .../subagent-continuable/session.1.jsonl | 63 +++++------ .../subagent-continuable/session.jsonl | 104 +++++++++--------- 3 files changed, 126 insertions(+), 128 deletions(-) diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 1f08074ca5..8352dac005 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -1,72 +1,83 @@ import type { Context } from 'cordis' -import { appendFileSync } from 'node:fs' +import { SessionId } from '@deepseek-ai/dsh-session' export const name = 'subagent-durability-failure' export const inject = ['sessionPersistence', 'subagents'] +/** + * The authored parent transcript names the background child by a stable + * placeholder id, but the live continuable child is minted with a fresh random + * session id at run time. This snapshot-only overlay bridges that gap and forces + * a deterministic ordering plus a failing final child durability checkpoint: + * + * - `PLACEHOLDER_CHILD_ID` in a scripted `send_message` is remapped to the real + * child so both follow-ups queue onto the same live inbox in FIFO order. + * - The unknown-id `send_message` (`UNKNOWN_CHILD_ID`) resolves through a + * persistence load fenced behind both accepted follow-ups, so the transcript + * records the same order on every runner. + * - The child's final continuation turn fails its durability checkpoint with a + * fixed message, so the scenario proves child-first disposal survives a failed + * last flush. + */ +const PLACEHOLDER_CHILD_ID = '33333333-3333-4333-8333-333333333333' const UNKNOWN_CHILD_ID = '22222222-2222-4222-8222-222222222222' +/** The child continuation turn whose durability checkpoint is forced to fail. */ +const FAILED_CHECKPOINT_TURN = 4 /** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */ export function apply(ctx: Context): void { - const log = (...a: unknown[]): void => { - try { appendFileSync('/tmp/probe.log', '[PROBE] ' + a.map(String).join(' ') + '\n') } catch { /* ignore */ } - } const followupsAccepted = Promise.withResolvers() const persistence = ctx.sessionPersistence const load = persistence.load.bind(persistence) + // The unavailable-child lookup is real asynchronous I/O. Fence it behind both + // authored follow-ups so runner speed cannot reorder the exact log. persistence.load = async (id) => { - log('load', id) - if (id === UNKNOWN_CHILD_ID) { log('gating unknown-id load'); await followupsAccepted.promise; log('unknown-id load released') } + if (id === UNKNOWN_CHILD_ID) await followupsAccepted.promise return load.call(persistence, id) } - - // Patch followup to log routing. - const subagents = ctx.subagents as unknown as { followup: (...a: unknown[]) => Promise } - const origFollowup = subagents.followup.bind(subagents) - subagents.followup = async (...args: unknown[]) => { - log('followup childId=', args[1]) - return origFollowup(...args) - } - ctx.effect(() => () => { persistence.load = load followupsAccepted.resolve(undefined) }, 'subagent snapshot ordering') + // Remap the placeholder child id in a follow-up to the live child. The child + // id the model "knows" is authored into the transcript, while the running + // child is minted with a random id, so without this the follow-ups would + // never reach the live inbox. + let realChildId: string | undefined + const subagents = ctx.subagents as unknown as { + followup: (authority: unknown, childId: SessionId, content: unknown, options: unknown) => Promise + } + const deliver = subagents.followup.bind(subagents) + subagents.followup = (authority, childId, content, options) => { + const mapped = childId === PLACEHOLDER_CHILD_ID && realChildId !== undefined + ? SessionId(realChildId) + : childId + return deliver(authority, mapped, content, options) + } + + // Both authored follow-ups reach the child inbox before the unknown-id lookup + // runs, so the queued FIFO order is what the transcript records. The first + // child enqueue is the initial delegation, which also pins the real child id. let accepted = 0 ctx.on('agent/inbox/enqueue', (agent) => { if (agent.session.header.parentSession === undefined) return + if (realChildId === undefined) realChildId = agent.session.header.id accepted += 1 - log('child enqueue #', accepted, 'child=', agent.session.header.id) if (accepted >= 3) followupsAccepted.resolve(undefined) }) - ctx.on('subagent/start', (info: unknown) => { - log('subagent/start id=', (info as { id?: unknown }).id) - }) - + // The child's ordinary per-turn flushes succeed; only the final continuation + // turn's durability checkpoint fails, turning that turn/end into a durable + // error the parent never sees. + const childTurn = new WeakMap() ctx.on('session/event', (session, event) => { - if (session.header.parentSession === undefined) return - if (event.type === 'turn/start') log('child turn/start turn=', event.data.turn, 'child=', session.header.id) - if (event.type === 'user/message') { - const c = event.data.content?.[0] - log('child user/message text=', c && c.type === 'text' ? c.text : '?', 'child=', session.header.id) - } + if (session.header.parentSession === undefined || event.type !== 'turn/start') return + childTurn.set(session, event.data.turn) }) - - const flushes = new WeakMap() - const flushedTurnEnds = new WeakSet() ctx.on('session/flush', (session) => { if (session.header.parentSession === undefined) return - const count = (flushes.get(session) ?? 0) + 1 - flushes.set(session, count) - log('child flush #', count, 'child=', session.header.id) - if (session.events.at(-1)?.type !== 'turn/end') return - if (flushedTurnEnds.has(session)) { - log('THROW snapshot disk full') - throw new Error('snapshot disk full') - } - flushedTurnEnds.add(session) + if (childTurn.get(session) === FAILED_CHECKPOINT_TURN) throw new Error('snapshot disk full') }) } diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index 67451e499b..a5220ed410 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -2,39 +2,30 @@ {"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"subagent-descriptor"}}} {"type":"subagent/descriptor","seq":1,"time":1789000000002,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek","agentModel":"deepseek-v4-flash"}} {"type":"turn/end","seq":2,"time":1789000000003,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}},"seq":3,"time":1789000000004} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":4,"time":1789000000005} -{"type":"step/start","data":{"turn":2,"step":1},"seq":5,"time":1789000000006} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"},"seq":6,"time":1789000000007} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":7,"time":1789000000008} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}},"seq":8,"time":1789000000009} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}},"seq":9,"time":1789000000010} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":10,"time":1789000000011} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":11,"time":1789000000012} -{"type":"assistant/message","data":{"turn":2,"step":1,"content":[{"type":"text","text":"CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":12,"time":1789000000013} -{"type":"step/end","data":{"turn":2,"step":1},"seq":13,"time":1789000000014} -{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}},"seq":14,"time":1789000000015} -{"type":"turn/start","data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}},"seq":15,"time":1789000000016} -{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append","seq":16,"time":1789000000017} -{"type":"step/start","data":{"turn":3,"step":1},"seq":17,"time":1789000000018} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"},"seq":18,"time":1789000000019} -{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":19,"time":1789000000020} -{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}},"seq":20,"time":1789000000021} -{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}},"seq":21,"time":1789000000022} -{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":22,"time":1789000000023} -{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":23,"time":1789000000024} -{"type":"assistant/message","data":{"turn":3,"step":1,"content":[{"type":"text","text":"SECOND_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":24,"time":1789000000025} -{"type":"step/end","data":{"turn":3,"step":1},"seq":25,"time":1789000000026} -{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}},"seq":26,"time":1789000000027} -{"type":"turn/start","data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}},"seq":27,"time":1789000000028} -{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append","seq":28,"time":1789000000029} -{"type":"step/start","data":{"turn":4,"step":1},"seq":29,"time":1789000000030} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"},"seq":30,"time":1789000000031} -{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":31,"time":1789000000032} -{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"text-delta","index":0,"text":"THIRD_OK"}},"seq":32,"time":1789000000033} -{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"THIRD_OK"}}},"seq":33,"time":1789000000034} -{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":34,"time":1789000000035} -{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":35,"time":1789000000036} -{"type":"assistant/message","data":{"turn":4,"step":1,"content":[{"type":"text","text":"THIRD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":36,"time":1789000000037} -{"type":"step/end","data":{"turn":4,"step":1},"seq":37,"time":1789000000038} -{"type":"turn/end","data":{"turn":4,"reason":{"kind":"error","step":1,"message":"snapshot disk full"}},"seq":38,"time":1789000000039} +{"type":"turn/start","seq":3,"time":1789000000004,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":4,"time":1789000000005,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1789000000005,"data":{"title":"Reply with exactly the word","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":6,"time":1789000000006,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":7,"time":1789000000007,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":8,"time":1789000000008,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":9,"time":1789000000009,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":10,"time":1789000000010,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":11,"time":1789000000011,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":12,"time":1789000000012,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":13,"time":1789000000013,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"step/end","seq":14,"time":1789000000014,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":15,"time":1789000000015,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":16,"time":1789000000016,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} +{"type":"user/message","seq":17,"time":1789000000017,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append"} +{"type":"step/start","seq":18,"time":1789000000018,"data":{"turn":3,"step":1}} +{"type":"assistant/chunk","seq":19,"time":1785394678743,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":1789000000020,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} +{"type":"assistant/chunk","seq":21,"time":1789000000021,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} +{"type":"assistant/chunk","seq":22,"time":1789000000022,"data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":1789000000023,"data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1785394678743,"data":{"turn":3,"step":1,"content":[{"type":"text","text":"SECOND_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785394678743,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":26,"time":1785394678743,"data":{"turn":3,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":27,"time":1785394678756,"data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} +{"type":"user/message","seq":28,"time":1785394678756,"data":{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append"} +{"type":"turn/end","seq":29,"time":1785394678762,"data":{"turn":4,"reason":{"kind":"error","step":1,"message":"snapshot disk full"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index 1b28826e21..4dff6a044a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -1,58 +1,54 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"/tmp/subagent-continuable","delegationDepth":0} {"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1789000000002,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Collect its result with task_output using the task id from the acknowledgement and wait: true. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.'. 4. Collect the task it started with task_output and wait: true, and observe that it failed. 5. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1789000000002,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1789000000003,"data":{"title":"Follow these steps exactly, then","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1789000000004} +{"type":"step/start","seq":3,"time":1789000000004,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1789000000005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":5,"time":1789000000006} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}},"seq":6,"time":1789000000007} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}},"seq":7,"time":1789000000008} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":8,"time":1789000000009} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":9,"time":1789000000010} -{"type":"assistant/message","data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":10,"time":1789000000011} -{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"},"seq":11,"time":1789000000012} -{"type":"tool/result","data":{"turn":1,"step":1,"callId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}]},"seq":12,"time":1789000000013} -{"type":"step/end","data":{"turn":1,"step":1},"seq":13,"time":1789000000014} -{"type":"step/start","data":{"turn":1,"step":2},"seq":14,"time":1789000000015} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":15,"time":1789000000016} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":16,"time":1789000000017} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_1","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}},"seq":17,"time":1789000000018} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}},"seq":18,"time":1789000000019} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":19,"time":1789000000020} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":20,"time":1789000000021} -{"type":"assistant/message","data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":21,"time":1789000000022} -{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"},"seq":22,"time":1789000000023} -{"type":"tool/result","data":{"turn":1,"step":2,"callId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}]},"seq":23,"time":1789000000024} -{"type":"step/end","data":{"turn":1,"step":2},"seq":24,"time":1789000000025} -{"type":"step/start","data":{"turn":1,"step":3},"seq":25,"time":1789000000026} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":26,"time":1789000000027} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":27,"time":1789000000028} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_2","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}},"seq":28,"time":1789000000029} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}},"seq":29,"time":1789000000030} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":30,"time":1789000000031} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":31,"time":1789000000032} -{"type":"assistant/message","data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":32,"time":1789000000033} -{"type":"tool/call","data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"},"seq":33,"time":1789000000034} -{"type":"tool/result","data":{"turn":1,"step":3,"callId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}]},"seq":34,"time":1789000000035} -{"type":"step/end","data":{"turn":1,"step":3},"seq":35,"time":1789000000036} -{"type":"step/start","data":{"turn":1,"step":4},"seq":36,"time":1789000000037} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":37,"time":1789000000038} -{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":38,"time":1789000000039} -{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_unknown","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}},"seq":39,"time":1789000000040} -{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}},"seq":40,"time":1789000000041} -{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":41,"time":1789000000042} -{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":42,"time":1789000000043} -{"type":"assistant/message","data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":43,"time":1789000000044} -{"type":"tool/call","data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"},"seq":44,"time":1789000000045} -{"type":"tool/result","data":{"turn":1,"step":4,"callId":"call_followup_unknown","content":[{"type":"text","text":"subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true},"seq":45,"time":1789000000046} -{"type":"step/end","data":{"turn":1,"step":4},"seq":46,"time":1789000000047} -{"type":"step/start","data":{"turn":1,"step":5},"seq":47,"time":1789000000048} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":48,"time":1789000000049} -{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":49,"time":1789000000050} -{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}},"seq":50,"time":1789000000051} -{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},"seq":51,"time":1789000000052} -{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":52,"time":1789000000053} -{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":53,"time":1789000000054} -{"type":"assistant/message","data":{"turn":1,"step":5,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":54,"time":1789000000055} -{"type":"step/end","data":{"turn":1,"step":5},"seq":55,"time":1789000000056} -{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":56,"time":1789000000057} +{"type":"assistant/chunk","seq":5,"time":1789000000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} +{"type":"assistant/chunk","seq":7,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} +{"type":"assistant/chunk","seq":8,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":9,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":1789000000011,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":1789000000012,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} +{"type":"tool/result","seq":12,"time":1789000000013,"data":{"turn":1,"step":1,"callId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":1789000000014,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":1789000000015,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":1785394678688,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":16,"time":1789000000017,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_1","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}} +{"type":"assistant/chunk","seq":17,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}}} +{"type":"assistant/chunk","seq":18,"time":1789000000019,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":19,"time":1789000000020,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":20,"time":1785394678689,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":1785394678689,"data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}} +{"type":"tool/result","seq":22,"time":1785394678701,"data":{"turn":1,"step":2,"callId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1785394678701,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":1785394678713,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":1785394678718,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":1785394678719,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_2","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1789000000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}}} +{"type":"assistant/chunk","seq":28,"time":1789000000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":29,"time":1789000000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":1785394678719,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1785394678719,"data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}} +{"type":"tool/result","seq":32,"time":1785394678733,"data":{"turn":1,"step":3,"callId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1785394678733,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":1785394678746,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":1785394678752,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1785394678753,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_unknown","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}} +{"type":"assistant/chunk","seq":37,"time":1785394678753,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} +{"type":"assistant/chunk","seq":38,"time":1789000000039,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":39,"time":1789000000040,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":1785394678753,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1785394678753,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} +{"type":"tool/result","seq":42,"time":1785394678765,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","content":[{"type":"text","text":"Error: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true,"error":{"name":"SubagentError","code":"NOT_RESUMABLE"}},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":1785394678765,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":1785394678774,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":1785394678778,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":46,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":47,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":48,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":49,"time":1789000000050,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":50,"time":1785394678779,"data":{"turn":1,"step":5,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"step/end","seq":51,"time":1785394678779,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":52,"time":1785394678779,"data":{"turn":1,"reason":{"kind":"completed"}}} From 03973cb074bbd8439182c503ada91e5e14a9b9e5 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 15:09:12 +0800 Subject: [PATCH 061/114] test(acp-agent): refresh header pins for the new subagent tool wording Every scenario compares its live tool schemas and system prompt against the shared header pins, so the Task-free subagent and send_message descriptions change all 14 pin sidecars. The diff is only that wording plus the tools' output-type shapes. --- .../system-prompt.expected.md | 21 +++++++++++-------- .../tool-schemas.expected.json | 10 ++++----- .../both-mode-turn/tool-schemas.expected.json | 10 ++++----- .../code-mode-turn/system-prompt.expected.md | 21 +++++++++++-------- .../lsp-definition/tool-schemas.expected.json | 10 ++++----- .../pty-tools/tool-schemas.expected.json | 10 ++++----- .../tool-schemas.expected.json | 10 ++++----- .../text-turn/tool-schemas.expected.json | 10 ++++----- .../web-fetch/tool-schemas.expected.json | 10 ++++----- 9 files changed, 59 insertions(+), 53 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index e386a25eff..b1c44a10ea 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -110,7 +110,7 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered. */ + /** 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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; @@ -122,22 +122,22 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ + /** 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 to you, so read its transcript by that id, or send it more work with `send_message`. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ + /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ run_in_background?: boolean; } & Record; - /** 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ + /** 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 to you, so read its transcript by that id, or send it more work with `send_message`. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ + /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ @@ -317,8 +317,7 @@ interface ToolOutputMap { totalLines: number; }; send_message: { - route: "steered" | "started"; - taskId: string; + messageId: string; }; skill: { name: string; @@ -338,7 +337,9 @@ interface ToolOutputMap { subagent: { kind: "background"; taskId: string; - subagentId?: string; + } | { + kind: "continuable"; + subagentId: string; } | { kind: "foreground"; runId: string; @@ -347,7 +348,9 @@ interface ToolOutputMap { subagent_fork: { kind: "background"; taskId: string; - subagentId?: string; + } | { + kind: "continuable"; + subagentId: string; } | { kind: "foreground"; runId: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 00ae670288..7863590b88 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -239,7 +239,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -276,7 +276,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -290,7 +290,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -301,7 +301,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -315,7 +315,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 6d052ead19..585c601a84 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -182,7 +182,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -219,7 +219,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -233,7 +233,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -244,7 +244,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -258,7 +258,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 15b5e8dde6..e53e20d3ad 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -93,7 +93,7 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered. */ + /** 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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; @@ -105,22 +105,22 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ + /** 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 to you, so read its transcript by that id, or send it more work with `send_message`. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ + /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ run_in_background?: boolean; } & Record; - /** 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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ + /** 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 to you, so read its transcript by that id, or send it more work with `send_message`. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ + /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ @@ -288,8 +288,7 @@ interface ToolOutputMap { totalLines: number; }; send_message: { - route: "steered" | "started"; - taskId: string; + messageId: string; }; skill: { name: string; @@ -309,7 +308,9 @@ interface ToolOutputMap { subagent: { kind: "background"; taskId: string; - subagentId?: string; + } | { + kind: "continuable"; + subagentId: string; } | { kind: "foreground"; runId: string; @@ -318,7 +319,9 @@ interface ToolOutputMap { subagent_fork: { kind: "background"; taskId: string; - subagentId?: string; + } | { + kind: "continuable"; + subagentId: string; } | { kind: "foreground"; runId: string; diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 6124557f08..dd4be5f915 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -198,7 +198,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -235,7 +235,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -249,7 +249,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -260,7 +260,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -274,7 +274,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index d4c004034f..d80fe4b555 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -198,7 +198,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -223,7 +223,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index 72c6b74b72..59bb91d9ac 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -402,7 +402,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -416,7 +416,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -427,7 +427,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -441,7 +441,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index bf0103bbeb..ee1c0e158b 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -198,7 +198,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -223,7 +223,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index d143e9d82a..c94b51630d 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -198,7 +198,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -223,7 +223,7 @@ }, { "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 continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "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 to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ From 542a01c40738d3211a45a2570e945c66019900be Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 15:15:38 +0800 Subject: [PATCH 062/114] test(acp): cover the continuable drain ordering and its failure path Pins that the bridge releases the Activation forest before its own sessions, and that a failed drain is reported without stranding that teardown. Reads the one teardown method structurally so the bridge keeps no dependency on the subagent seam. --- packages/acp/acp/src/index.ts | 14 +++++++++- packages/acp/acp/tests/dispose.spec.ts | 38 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index d76a184f5e..d19a2e5753 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -43,6 +43,16 @@ export const name = 'acp' /** The bridge creates and owns agents; every other concern is carried by the agent composition. */ export const inject = ['agents'] +/** + * The single continuable-subagent teardown the bridge needs. Declared + * structurally so this package does not depend on the subagent seam for one + * shutdown hook; an absent service means nothing continuable was materialized. + */ +interface ContinuableDrain { + /** Close continuable admission, then dispose every live Activation child-first. */ + drainContinuable(): Promise +} + /** Preserve invalid-parameter detail in the SDK wire error message. */ function invalidParams(detail: string): RequestError { return RequestError.invalidParams(undefined, detail) @@ -331,7 +341,9 @@ export function apply(ctx: Context, config: AcpConfig): void { // Activations own descendant teardown. Drain that forest child-first // BEFORE disposing the top-level agents, so no descendant is left holding // a runtime its owner already released. - const subagents = ctx.get('subagents') + // Read the one teardown method structurally: the bridge needs no other + // part of the subagent seam, so it does not depend on that package. + const subagents = ctx.get('subagents') as ContinuableDrain | undefined if (subagents !== undefined) { try { await subagents.drainContinuable() diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index ee57d51baf..64bab702b3 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -25,6 +25,44 @@ describe('ACP connection ownership', () => { expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) + it('drains continuable subagents before disposing its own sessions', async () => { + harness = await makeBridgeHarness() + const order: string[] = [] + // A continuable Activation outlives the turn that started it, so the bridge + // must release that forest before the agents whose runtime it depends on. + harness.ctx.provide('subagents', { + drainContinuable: () => { + order.push('drained') + return Promise.resolve() + }, + } as never, true) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + harness.ctx.on('agent/disposed', () => { order.push('agent disposed') }) + + await harness.acpFiber.dispose() + + expect(order).toEqual(['drained', 'agent disposed']) + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + }) + + it('reports a failed continuable drain and still disposes its sessions', async () => { + harness = await makeBridgeHarness() + const warnings: string[] = [] + harness.ctx.logger.warn = (message: string) => { warnings.push(message) } + harness.ctx.provide('subagents', { + drainContinuable: () => Promise.reject(new Error('activation teardown failed')), + } as never, true) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await harness.acpFiber.dispose() + + // A stuck descendant must not strand the bridge's own teardown. + expect(warnings.some(warning => warning.includes('continuable subagent teardown failed'))).toBe(true) + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + }) + it('an ACP-only reload rejects new sessions before creating an orphan', async () => { harness = await makeBridgeHarness() await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) From 19d034169e6b22d3e90ba9da4d082a9a021b669d Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 15:19:53 +0800 Subject: [PATCH 063/114] docs: regenerate the config catalog source line The ACP bridge's new structural teardown type shifts its Config declaration. --- docs/config-catalog.md | 2 +- packages/acp/acp/tests/dispose.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 29ba89cbef..9b7be60536 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/acp/acp/src/index.ts:57`](../packages/acp/acp/src/index.ts) +Source: [`packages/acp/acp/src/index.ts:67`](../packages/acp/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 64bab702b3..b7b38b0ffb 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -35,7 +35,7 @@ describe('ACP connection ownership', () => { order.push('drained') return Promise.resolve() }, - } as never, true) + } as never) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) harness.ctx.on('agent/disposed', () => { order.push('agent disposed') }) @@ -52,7 +52,7 @@ describe('ACP connection ownership', () => { harness.ctx.logger.warn = (message: string) => { warnings.push(message) } harness.ctx.provide('subagents', { drainContinuable: () => Promise.reject(new Error('activation teardown failed')), - } as never, true) + } as never) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) From c485b6136d6411c898a3b2e1fad8c559daf1140d Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 16:01:11 +0800 Subject: [PATCH 064/114] fix(subagent): address codex review round 1 All five findings were real: - The terminal lifecycle edge derived its stop reason from teardown success, so a child that errored, hit its token ceiling, or was cancelled reported as completed once its checkpoint and disposal succeeded. It now reads the child's own last message turn/end, which is authoritative. - Live delivery never rechecked the caller signal after authorization yielded, so an abort that won before acceptance still enqueued the message and returned an id. Admission now re-checks at the boundary that owns the decision. - Drain flushed before cancelling, letting a running turn keep appending events the checkpoint could not cover and letting model work continue through a slow flush. It now cancels to quiescence first. - subagent/end fired after AgentHandle.dispose() unregistered the child, so the hooks bridge could not resolve it for the child's cwd and scope. The edge now publishes while the child is still registered. - activationState() read Agent.status alone, which stays idle between an accepted waking send and the microtask that admits it, so a synchronous inbox observer could see settled with a queued turn. Residency now also counts messages this manager admitted but has not seen leave the inbox. --- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 12 +- .../subagent/subagent/src/continuation.ts | 57 ++++++- packages/subagent/subagent/src/index.ts | 34 +++- .../subagent/tests/continuation.spec.ts | 146 ++++++++++++++++++ 6 files changed, 243 insertions(+), 16 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 877a6a44f2..4d08e8221a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -794,7 +794,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:141`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -811,7 +811,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:114`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:115`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -826,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:120`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:121`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -848,7 +848,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:131`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:132`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 11ba7fb59b..6e8ab17396 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2033,7 +2033,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [ActivationState](../core-data-structures/subagent.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentAuthority](../core-data-structures/subagent.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:173`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:174`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 696f4f8f66..d41a6b329b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -12,8 +12,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:327`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:327`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | | `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:317`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | @@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:131`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:141`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:115`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:121`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:132`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 3ab4d342ef..b7e0d87274 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -168,6 +168,12 @@ interface Activation { * a new Activation. Every converging releaser shares this one teardown. */ disposal: Promise | undefined + /** + * Accepted waking message ids this manager has not yet seen leave the inbox. + * `Agent.status` is still `idle` in the window between `followup()` and the + * microtask that admits it, so settlement must not treat that gap as quiet. + */ + readonly accepted: Set /** Renewed whenever a settlement watcher must re-observe quiescence. */ poke: PromiseWithResolvers } @@ -353,6 +359,11 @@ export class SubagentContinuationManager { return activation.disposal.then(() => undefined, () => undefined) } await this.authorizeLive(authority, activation) + // The caller signal owns admission until acceptance, so re-check it + // here: the outer check cannot cover an abort that landed while + // authorization yielded, and enqueueing afterwards would return a + // message id for a delivery the caller already cancelled. + options.signal.throwIfAborted() return this.submit(activation, content, options.source, authority) }) /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that @@ -413,10 +424,15 @@ export class SubagentContinuationManager { /** * Derive residency from Agent quiescence and the owned-child set. `running` - * covers an active admission, an open turn, or waking inbox work. + * covers an active admission, an open turn, or accepted waking inbox work. + * + * `Agent.status` alone is insufficient: it stays `idle` between an accepted + * waking send and the microtask that admits it, so a synchronous inbox + * observer would see `settled` while a turn is already queued. `accepted` + * holds the ids this manager admitted but has not yet seen drained. */ private stateOf(activation: Activation): ActivationState { - if (activation.handle.agent.status === 'running') return 'running' + if (activation.handle.agent.status === 'running' || activation.accepted.size > 0) return 'running' if (activation.ownedChildren.size > 0) return 'waiting' return 'settled' } @@ -528,6 +544,7 @@ export class SubagentContinuationManager { ownedChildren: new Set(), observer, disposal: undefined, + accepted: new Set(), poke: Promise.withResolvers(), } // After transfer, any failure must dispose the created handle, remove the @@ -550,6 +567,22 @@ export class SubagentContinuationManager { await activation.disposal.catch(() => undefined) throw error } + // Every accepted id leaves the inbox exactly once, through dequeue or + // discard. Clearing it there is what lets `stateOf()` distinguish a truly + // quiet Agent from one whose accepted turn has not been admitted yet. + // Registered through the child's own scoped context, so scope filtering + // already restricts both listeners to this exact agent. + handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => { + /* v8 ignore next -- a dequeue of an id this manager never admitted needs + * another sender on the same child, which no current path allows. */ + if (activation.accepted.delete(item.message.id)) this.wake(activation) + }) + handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => { + // Deleting every id in the batch is unconditional; waking once afterwards + // costs nothing and avoids branching on which ids this manager admitted. + for (const item of items) activation.accepted.delete(item.message.id) + this.wake(activation) + }) // Resident: publish the start edge before any turn can run, so observers // see this epoch before its first request. observer.start() @@ -604,7 +637,15 @@ export class SubagentContinuationManager { // establish it before the message can enter the child's inbox. if (authority.kind === 'parent') this.acquireOwnership(authority.agent, activation.childId) const message = createUserMessage({ content, source }) - activation.handle.agent.followup(message) + // `Agent.followup()` publishes `agent/inbox/enqueue` synchronously, so its + // observers must see this Activation as busy before the call begins. + activation.accepted.add(message.id) + try { + activation.handle.agent.followup(message) + } catch (error: unknown) { + activation.accepted.delete(message.id) + throw error + } // Accepted waking work keeps this Activation live until whenIdle() observes // the complete waking suffix. this.wake(activation) @@ -730,8 +771,17 @@ export class SubagentContinuationManager { 'ACTIVATION_TEARDOWN_FAILED', ) } + // Quiesce before the checkpoint: a turn still running would keep + // appending events the flush cannot cover, and a slow flush would let + // model and tool work continue for the whole shutdown. + activation.handle.agent.cancel({ kind: 'parent' }) + await activation.handle.agent.whenIdle() const durability = await this.checkpoint(activation) failure ??= durability + // Publish the terminal edge while the child is STILL registered: + // consumers resolve `ctx.agents.get(info.id)` in `subagent/end` to run + // in the child's own cwd and scope, which handle disposal removes. + activation.observer.settle(activation.handle.agent, failure) } finally { this.activations.delete(childId) try { @@ -746,7 +796,6 @@ export class SubagentContinuationManager { // Release ownership even on failure: a retained failed child would // pin its ancestors in `waiting` forever. this.releaseOwnership(childId) - activation.observer.settle(activation.handle.agent, failure) } } if (failure !== undefined) throw failure diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0789113e7e..03bea866a5 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -36,6 +36,7 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' +import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { ContinuableCreateRequest, @@ -377,7 +378,7 @@ export class SubagentService extends Service { const output = failure === undefined ? lastAssistantOutput(child) : undefined this.emitLifecycle('subagent/end', { ...identity, - stopReason: failure === undefined ? 'completed' : 'error', + stopReason: failure === undefined ? childStopReason(child) : 'error', ...output === undefined ? {} : { lastAssistantMessage: output }, }, parent) }, @@ -459,6 +460,37 @@ export class SubagentService extends Service { } } +/** + * Why this child's last ordinary turn ended, for the terminal lifecycle edge. + * The child's own `turn/end` is authoritative: teardown succeeding says nothing + * about whether the model errored, hit its token ceiling, or was cancelled, so + * deriving the reason from disposal would report failed work as completed. + * @param child - the settling child agent whose log is read. + * @returns its terminal stop reason; `completed` when no ordinary turn closed. + */ +function childStopReason(child: Agent): SubagentResult['stopReason'] { + const reason = findLastMessageTurnEnd(child.session.events)?.data.reason + // No ordinary turn closed, so nothing failed either. + if (reason === undefined) return 'completed' + switch (reason.kind) { + case 'max-tokens': + return 'max-tokens' + case 'aborted': + case 'interrupted': + case 'disposed': + return 'aborted' + case 'error': + return 'error' + case 'completed': + return 'completed' + /* v8 ignore next 3 -- `TurnEndReason` is merge-extensible, so this arm needs a + * backend that adds a variant; treating an unnameable reason as success would + * report failed work as completed. */ + default: + return 'error' + } +} + /** * The child's last assistant message content, for one Activation's terminal * lifecycle edge. Absent when no assistant message reached the log. diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 612c6763e8..feb18d196a 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -643,6 +643,152 @@ describe('continuable durability and teardown', () => { }) }) +describe('continuable review regressions', () => { + it('reports the child\'s own terminal reason, not teardown success', async () => { + // The child hits its token ceiling; teardown still succeeds. + const { ctx, parent } = await setupWith(new MockAdapter([ + [{ type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'partial' }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'partial' } }, + { type: 'finish', reason: { kind: 'max-tokens' } }], + ])) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + // Deriving this from disposal success would report the failure as completed. + expect(ends[0]!.stopReason).toBe('max-tokens') + }) + + it('rejects a live delivery whose caller signal aborted before admission', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const before = child.session.events.length + + const controller = new AbortController() + controller.abort('caller gave up') + await expect(followup(ctx, { kind: 'user' }, started.childId, message('cancelled'), controller.signal)) + .rejects.toThrow() + + // Nothing was enqueued, so no later turn can carry it. + releaseFirst.resolve(undefined) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'cancelled')).toBe(false) + expect(before).toBeGreaterThan(0) + }) + + it('publishes the terminal edge while the child agent is still resolvable', async () => { + const { ctx, parent } = await setup([textResponse('answer')]) + const resolvable: boolean[] = [] + // Consumers resolve the child in `subagent/end` to run in its own cwd. + ctx.on('subagent/end', (info) => { + resolvable.push(ctx.agents.get(info.id) !== undefined) + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + await vi.waitFor(() => { expect(resolvable).toHaveLength(1) }) + expect(resolvable[0]).toBe(true) + }) + + it('cancels a running turn before the final durability checkpoint', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('slow'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const order: string[] = [] + ctx.on('session/flush', (session) => { + if (session.header.parentSession !== undefined) order.push('flush') + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + child.ctx.on('agent/cancel-requested', () => { order.push('cancel') }) + + const drained = ctx.subagents.drainContinuable() + hold.resolve(undefined) + await drained + + // Flushing a still-running turn cannot cover the events cancellation adds. + expect(order.indexOf('cancel')).toBeGreaterThanOrEqual(0) + expect(order.indexOf('cancel')).toBeLessThan(order.lastIndexOf('flush')) + }) + + it('releases an accepted message that is discarded instead of run', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + // Queue a turn, then cancel so it is discarded rather than dequeued. The + // Activation must still reach settlement instead of waiting on that id. + await followup(ctx, { kind: 'user' }, started.childId, message('discarded')) + + const drained = ctx.subagents.drainContinuable() + hold.resolve(undefined) + await drained + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'discarded')).toBe(false) + }) + + it('reports completed when no ordinary turn closed', async () => { + const { ctx, parent } = await setup([]) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) + // Block admission so the child's only turn never opens. + ctx.on('agent/prompt-submit', async (subject, _content, _source, _signal, next) => { + if (subject === parent) return next() + return { kind: 'block', reason: 'blocked by policy' } + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + expect(ends[0]!.stopReason).toBe('completed') + }) + + it('never reports settled while an accepted message is still in the inbox', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('first'), gate: releaseFirst.promise }, + { chunks: textResponse('second') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const states: (string | undefined)[] = [] + // A synchronous inbox observer runs before the admitting microtask, the + // exact window where `Agent.status` is still idle. + ctx.on('agent/inbox/enqueue', (agent) => { + if (agent.session.header.parentSession !== undefined) { + states.push(ctx.subagents.activationState(agent.id)) + } + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + await followup(ctx, { kind: 'user' }, started.childId, message('queued')) + + expect(states.length).toBeGreaterThan(0) + expect(states).not.toContain('settled') + releaseFirst.resolve(undefined) + await waitNoActivation(ctx, started.childId) + }) +}) + describe('continuable lifecycle observation', () => { it('emits one paired start/end per residency epoch', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) From cbaceb73a98a6ef41c06d72a186f1911fb64c151 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 16:18:15 +0800 Subject: [PATCH 065/114] fix(subagent): address codex review round 2 Round 1 traded one teardown ordering problem for another. The observer now splits capture from emission, which satisfies both consumers at once: - Terminal facts are captured while the child is still registered, so consumers that resolve it for the child's log and scope still work. - The edge is emitted only after handle disposal settles, so a rejecting scoped cleanup is reported as a failed epoch instead of a successful one. Also: - Keep the Activation in the map until disposal settles. Removing it first let a racing followup() see no Activation and cold-resume into the still-registered agent, and let a concurrent forest drain skip a still-disposing child and release its parent first. - Derive terminal telemetry from this epoch's event suffix rather than the whole session, so a cold resume whose prompt is blocked no longer reports the previous epoch's answer and turn reason. - Cancel the ACP bridge's own prompts before awaiting the descendant drain: a drain can block on persistence, and the top-level agents must not keep running model and tool work for its whole duration. --- packages/acp/acp/src/index.ts | 12 ++-- packages/acp/acp/tests/dispose.spec.ts | 28 ++++++++ .../subagent/subagent/src/continuation.ts | 42 +++++++---- packages/subagent/subagent/src/index.ts | 38 +++++++--- .../subagent/tests/continuation.spec.ts | 70 ++++++++++++++++--- 5 files changed, 153 insertions(+), 37 deletions(-) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index d19a2e5753..2c877df7c4 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -336,6 +336,13 @@ export function apply(ctx: Context, config: AcpConfig): void { closed = true const records = [...sessions.values()] sessions.clear() + // Stop the bridge's own work before any await: a descendant drain can block + // on persistence or scoped cleanup, and the top-level agents must not keep + // running model and tool calls for its whole duration. + for (const record of records) { + record.agent.cancel({ kind: 'user' }) + settlePrompt(record, 'cancelled') + } quiescing = (async () => { // Continuable subagents outlive the turn that started them, and their // Activations own descendant teardown. Drain that forest child-first @@ -351,10 +358,7 @@ export function apply(ctx: Context, config: AcpConfig): void { logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`) } } - await Promise.all(records.map(async (record) => { - settlePrompt(record, 'cancelled') - await record.dispose() - })) + await Promise.all(records.map(record => record.dispose())) })() return quiescing } diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index b7b38b0ffb..4be0810513 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -46,6 +46,34 @@ describe('ACP connection ownership', () => { expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) + it('cancels its own prompt before awaiting the descendant drain', async () => { + harness = await makeBridgeHarness({ script: ['hang'] }) + const order: string[] = [] + const release = Promise.withResolvers() + harness.ctx.provide('subagents', { + drainContinuable: async () => { + order.push('drain started') + await release.promise + order.push('drain finished') + }, + } as never) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(sessionId))! + void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) + await vi.waitFor(() => { expect(agent.status).toBe('running') }) + harness.ctx.on('agent/cancel-requested', () => { order.push('parent cancelled') }) + + const disposal = harness.acpFiber.dispose() + // A drain can block on persistence, so the bridge's own turn must already be + // cancelled rather than running for its whole duration. + await vi.waitFor(() => { expect(order).toContain('drain started') }) + expect(order).toEqual(['parent cancelled', 'drain started']) + release.resolve(undefined) + await disposal + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + }) + it('reports a failed continuable drain and still disposes its sessions', async () => { harness = await makeBridgeHarness() const warnings: string[] = [] diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index b7e0d87274..9974a355a4 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -108,17 +108,26 @@ export type ActivationState = 'running' | 'waiting' | 'settled' * children emit the same start/end pair as one-shot runs. */ export interface ActivationObserver { - /** Publish the start edge once the epoch is resident. */ - start(): void /** - * Publish the terminal edge exactly once, pairing this epoch's {@link start}. - * Called only for a resident epoch: a failure before residency publishes no - * edge at all, because inventing one would report a lifecycle the child never - * had. - * @param child - the child agent whose final output the edge reports. + * Publish the start edge once the epoch is resident. + * @param child - the resident child agent, whose log suffix bounds this epoch. + */ + start(child: Agent): void + /** + * Snapshot the child-dependent terminal facts while the child is still + * registered, because handle disposal unregisters it and consumers resolve it + * to read the child's own log and scope. + * @param child - the quiescent child agent about to be released. + */ + capture(child: Agent): void + /** + * Publish the terminal edge exactly once, pairing this epoch's {@link start}, + * after the disposal outcome is known. Called only for a resident epoch: a + * failure before residency publishes no edge, because inventing one would + * report a lifecycle the child never had. * @param failure - the teardown or durability failure, or `undefined` on success. */ - settle(child: Agent, failure: unknown): void + settle(failure: unknown): void } /** Hooks the manager needs from the owning service. */ @@ -585,7 +594,7 @@ export class SubagentContinuationManager { }) // Resident: publish the start edge before any turn can run, so observers // see this epoch before its first request. - observer.start() + observer.start(handle.agent) this.watchSettlement(activation) return activation } @@ -778,12 +787,10 @@ export class SubagentContinuationManager { await activation.handle.agent.whenIdle() const durability = await this.checkpoint(activation) failure ??= durability - // Publish the terminal edge while the child is STILL registered: - // consumers resolve `ctx.agents.get(info.id)` in `subagent/end` to run - // in the child's own cwd and scope, which handle disposal removes. - activation.observer.settle(activation.handle.agent, failure) + // Capture the child-dependent edge data while the child is still live: + // handle disposal unregisters it, and consumers read its log and scope. + activation.observer.capture(activation.handle.agent) } finally { - this.activations.delete(childId) try { await activation.handle.dispose() } catch (error: unknown) { @@ -793,9 +800,16 @@ export class SubagentContinuationManager { { cause: error }, ) } finally { + // Only now is the Activation gone: keeping the entry until disposal + // settles makes a racing delivery wait for release rather than + // cold-resume into the still-registered agent. + this.activations.delete(childId) // Release ownership even on failure: a retained failed child would // pin its ancestors in `waiting` forever. this.releaseOwnership(childId) + // Emit once the disposal outcome is known, so a rejecting scoped + // cleanup cannot be reported as a successful epoch. + activation.observer.settle(failure) } } if (failure !== undefined) throw failure diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 03bea866a5..a5e120c860 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -363,22 +363,40 @@ export class SubagentService extends Service { parent: Agent | undefined, ): ActivationObserver { const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true } + // A cold resume replays earlier turns, so this epoch's telemetry must come + // from the suffix it actually produced — never the whole session, which + // would report a previous epoch's answer when this one opened no turn. + let boundary = 0 + // Assigned by `capture()`, which the disposal path always runs before + // `settle()`; a resident epoch therefore always has its facts by then. + let captured: { stopReason: SubagentResult['stopReason']; output?: ContentBlock[] } = { + stopReason: 'completed', + } let settled = false return { - start: (): void => { + start: (child: Agent): void => { + boundary = child.session.events.length this.emitLifecycle('subagent/start', identity, parent) }, - settle: (child: Agent, failure: unknown): void => { + capture: (child: Agent): void => { + const own = child.session.events.slice(boundary) + const output = lastAssistantOutput(own) + captured = { + stopReason: epochStopReason(own), + ...output === undefined ? {} : { output }, + } + }, + settle: (failure: unknown): void => { // Exactly one terminal edge per epoch: host shutdown, manager unload, // child release, and normal settlement all converge on one disposal. /* v8 ignore next -- the memoized disposal already collapses those callers into a * single settle(); this guard keeps the edge single if that memoization ever changes. */ if (settled) return settled = true - const output = failure === undefined ? lastAssistantOutput(child) : undefined + const output = failure === undefined ? captured.output : undefined this.emitLifecycle('subagent/end', { ...identity, - stopReason: failure === undefined ? childStopReason(child) : 'error', + stopReason: failure === undefined ? captured.stopReason : 'error', ...output === undefined ? {} : { lastAssistantMessage: output }, }, parent) }, @@ -465,11 +483,11 @@ export class SubagentService extends Service { * The child's own `turn/end` is authoritative: teardown succeeding says nothing * about whether the model errored, hit its token ceiling, or was cancelled, so * deriving the reason from disposal would report failed work as completed. - * @param child - the settling child agent whose log is read. + * @param events - this epoch's own event suffix. * @returns its terminal stop reason; `completed` when no ordinary turn closed. */ -function childStopReason(child: Agent): SubagentResult['stopReason'] { - const reason = findLastMessageTurnEnd(child.session.events)?.data.reason +function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopReason'] { + const reason = findLastMessageTurnEnd(events)?.data.reason // No ordinary turn closed, so nothing failed either. if (reason === undefined) return 'completed' switch (reason.kind) { @@ -494,11 +512,11 @@ function childStopReason(child: Agent): SubagentResult['stopReason'] { /** * The child's last assistant message content, for one Activation's terminal * lifecycle edge. Absent when no assistant message reached the log. - * @param child - the settling child agent whose log is read. + * @param events - this epoch's own event suffix. * @returns its final assistant content, or `undefined` when it produced none. */ -function lastAssistantOutput(child: Agent): ContentBlock[] | undefined { - const message = child.session.events.findLast( +function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined { + const message = events.findLast( (event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message', ) return message?.data.message.content diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index feb18d196a..c20cc175a9 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -685,19 +685,71 @@ describe('continuable review regressions', () => { expect(before).toBeGreaterThan(0) }) - it('publishes the terminal edge while the child agent is still resolvable', async () => { - const { ctx, parent } = await setup([textResponse('answer')]) - const resolvable: boolean[] = [] - // Consumers resolve the child in `subagent/end` to run in its own cwd. - ctx.on('subagent/end', (info) => { - resolvable.push(ctx.agents.get(info.id) !== undefined) - }) + it('reports this epoch\'s own output, captured while the child was still live', async () => { + const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + // Handle disposal unregisters the child, so the edge's content must have + // been captured before that — an after-the-fact lookup would find nothing. + expect(ends[0]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'first answer' }]) - await vi.waitFor(() => { expect(resolvable).toHaveLength(1) }) - expect(resolvable[0]).toBe(true) + // A cold resume is a new epoch: it must report its OWN answer, never the + // previous epoch's, which the replayed transcript still contains. + await followup(ctx, { kind: 'user' }, started.childId, message('again')) + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { expect(ends).toHaveLength(2) }) + expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }]) + }) + + it('reports a resumed epoch that opened no turn without the previous answer', async () => { + const { ctx, parent } = await setup([textResponse('first answer')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) + // Block the resumed prompt so this epoch produces nothing of its own. + ctx.on('agent/prompt-submit', async (subject, _content, _source, _signal, next) => { + if (subject === parent) return next() + return { kind: 'block', reason: 'blocked by policy' } + }) + await followup(ctx, { kind: 'user' }, started.childId, message('again')) + await waitNoActivation(ctx, started.childId) + + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + // Reading the whole session would resurrect 'first answer' here. + expect(ends[0]!.lastAssistantMessage).toBeUndefined() + expect(ends[0]!.stopReason).toBe('completed') + }) + + it('reports handle-disposal failure on the terminal edge', async () => { + const { ctx, parent } = await setup([textResponse('answer')]) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map Promise } }> } + }).continuations + const activation = await vi.waitFor(() => { + const found = manager.activations.get(started.childId) + expect(found).toBeDefined() + return found! + }) + const realDispose = activation.handle.dispose.bind(activation.handle) + activation.handle.dispose = async () => { + await realDispose() + throw new Error('scoped cleanup failed') + } + + await expect(ctx.subagents.drainContinuable()).rejects.toThrow() + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + // Emitting before disposal would have reported this failed epoch as success. + expect(ends[0]!.stopReason).toBe('error') }) it('cancels a running turn before the final durability checkpoint', async () => { From 7428cdf41e8680d949ae173f3fd6f39b9107e56f Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 16:48:42 +0800 Subject: [PATCH 066/114] fix(subagent): address codex review round 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make host-user authority unforgeable. `{ kind: 'user' }` was a bare discriminant, so any plugin holding `ctx.subagents` — including model-generated cordis_mount code, which the advanced ACP composition ships alongside continuable subagents — could construct it and skip the direct-parent check for any known child id. It now carries an opaque grant that only SubagentService.userAuthority() mints, which composition hands to trusted host adapters; a model-facing tool uses parent authority from its own execution context. - Reconcile a delivery discarded inside its own admission window. An enqueue listener that cancels fires the discard before followup() returns, so the discard listener could not clear an id it had not seen; submit() retained it and residency stayed `running` until an explicit drain. - Recheck the caller signal after materialization. An abort landing between publication and inbox acceptance still submitted the prompt and returned both ids; it now rolls the child back. - Stop promising the model transcript access that no shipped continuable config mounts. The tools now state only that a background child does not report back. - Restate the implemented note as shipped state rather than a proposal, so it works as current authority. --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 40 +++---- ...8-continuable-subagent-conversations.zh.md | 40 +++---- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 11 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 12 ++- docs/core-data-structures/subagent.zh.md | 10 +- docs/event-producer-consumer.md | 8 +- docs/tool-catalog.md | 2 +- .../system-prompt.expected.md | 10 +- .../tool-schemas.expected.json | 10 +- .../both-mode-turn/tool-schemas.expected.json | 10 +- .../code-mode-turn/system-prompt.expected.md | 10 +- .../lsp-definition/tool-schemas.expected.json | 10 +- .../pty-tools/tool-schemas.expected.json | 10 +- .../tool-schemas.expected.json | 10 +- .../text-turn/tool-schemas.expected.json | 10 +- .../web-fetch/tool-schemas.expected.json | 10 +- .../cordis/tool-cordis/src/api-catalog.ts | 10 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 3 +- packages/subagent/subagent/README.zh.md | 3 +- .../subagent/subagent/src/continuation.ts | 50 ++++++++- packages/subagent/subagent/src/index.ts | 24 ++++- .../subagent/tests/continuation.spec.ts | 101 ++++++++++++++---- .../subagent/subagent/tests/service.spec.ts | 2 +- .../tool-subagent-control/src/index.ts | 4 +- packages/subagent/tool-subagent/src/index.ts | 8 +- 29 files changed, 297 insertions(+), 141 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index f9a3bfc16c..7e2a36b502 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.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-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: 5ab17ea13d15d66afab4fee6766b082dd207b8a3 -2026-07-28-continuable-subagent-conversations.zh.md: eb14ebcec9682432682f6b5b4d8399f35b6882a2 +2026-07-28-continuable-subagent-conversations.md: a56da8ad389964dcc873a722a66e335062811f37 +2026-07-28-continuable-subagent-conversations.zh.md: 71089cd71ae6fda7712ffcc614852a483e13e3ba diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index 5ab17ea13d..a56da8ad38 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -4,13 +4,13 @@ Status: implemented English | [中文](2026-07-28-continuable-subagent-conversations.zh.md) -This proposal would replace the Task-backed continuation manager from [Continuable background subagents](../../implemented/feature/2026-07-21-continuable-background-subagents.md). It retains the single `ctx.subagents` service from [Merge subagent control into the subagent service](../../implemented/simplification/2026-07-26-merge-subagent-control-service.md) and the intent-named `followup` operation from [Intent-named subagent continuation operations](../../implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md). +This record replaces the Task-backed continuation manager from [Continuable background subagents](../../implemented/feature/2026-07-21-continuable-background-subagents.md). It retains the single `ctx.subagents` service from [Merge subagent control into the subagent service](../../implemented/simplification/2026-07-26-merge-subagent-control-service.md) and the intent-named `followup` operation from [Intent-named subagent continuation operations](../../implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md). ## Problem -The continuation manager currently makes one Task, one provider execution, and one result boundary the same object lifetime. Task settlement disposes the child Agent, Task completion injects the completion notice, and later input reconstructs another Agent. This couples a generic background-work abstraction to conversation delivery even though a continuable subagent already has a Session and an Agent inbox. +The previous continuation manager made one Task, one provider execution, and one result boundary the same object lifetime. Task settlement disposed the child Agent, Task completion injected the completion notice, and later input reconstructed another Agent. That coupled a generic background-work abstraction to conversation delivery even though a continuable subagent already has a Session and an Agent inbox. -Giving queued parent requests to the continuation manager and user messages to the Agent creates two FIFOs with no single ordering authority. Giving both to Tasks instead duplicates the Agent loop's admission, cancellation, and quiescence machinery. `Agent.whenIdle()` cannot recover a per-request Task result because one running interval may drain multiple queued turns, and broad `Agent.cancel()` cannot remove one queued request exactly. +Giving queued parent requests to the continuation manager and user messages to the Agent would create two FIFOs with no single ordering authority. Giving both to Tasks instead duplicated the Agent loop's admission, cancellation, and quiescence machinery. `Agent.whenIdle()` cannot recover a per-request Task result because one running interval may drain multiple queued turns, and broad `Agent.cancel()` cannot remove one queued request exactly. The runtime lifetime is also wider than one turn. A subagent can finish its own turn while a child it created is still running. Disposing the parent runtime at that point removes the Agent that still owns descendant teardown. Keeping every historical subagent resident instead would make memory use unbounded. @@ -30,7 +30,7 @@ persisted Session An Activation is one residency epoch for a reconstructed child Agent. It may execute multiple FIFO turns and remain resident while waiting for descendants. It is not a request, result, cancellation, or Task boundary. -The continuation manager owns activation admission, authority checks, the live ownership graph, cold resume, and child-first disposal. The Agent loop owns all turn ordering and execution. The proposal creates no Task for a continuable subagent, no Activation FIFO, and no queued Activation state. +The continuation manager owns activation admission, authority checks, the live ownership graph, cold resume, and child-first disposal. The Agent loop owns all turn ordering and execution. No continuable subagent has a Task, an Activation FIFO, or queued Activation state. ### Materialization and public operations @@ -54,7 +54,7 @@ The Session owns the stable child identity, transcript, direct-parent lineage, d An idle historical Session has no `AgentHandle`. The first authorized `next-turn` delivery resumes an Activation from the persisted Session and submits the message to its inbox. A user-authorized cold resume does not load the historical parent Agent. A parent-originated resume uses the exact live parent Agent for authorization and, when that parent has an Activation, ownership; it never uses the parent for reconstruction. -The Activation directly owns the published `AgentHandle` until it settles, while the manager's private activation-owner scope is its structural Cordis owner. The continuable path creates no intermediate result-bearing execution wrapper, including `SubagentRun`; one-shot delegation remains unchanged and outside this lifecycle. Remote providers are outside the MVP and require a separate Activation ownership contract when introduced. Historical Sessions consume no runtime memory after their Activation is disposed. +The Activation directly owns the published `AgentHandle` until it settles, while the manager's private activation-owner scope is its structural Cordis owner. The continuable path creates no intermediate result-bearing execution wrapper, including `SubagentRun`; one-shot delegation remains unchanged and outside this lifecycle. Remote providers are out of scope here and require a separate Activation ownership contract when introduced. Historical Sessions consume no runtime memory after their Activation is disposed. ### Activation lifecycle @@ -107,7 +107,7 @@ Child release occurs only after the child Agent is quiescent, every child of tha A user cold-resume creates an Activation without adding it to the historical parent's `ownedChildren`. If the direct parent later submits work to that live Activation and is itself continuation-managed, admission establishes ownership before enqueueing the message; a non-continuation parent remains outside the waiting graph. -The MVP retains ownership until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. +Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain and includes user-resumed Activations without live owners. @@ -115,13 +115,13 @@ The activation-owner scope exists because ordinary Cordis owner effects unwind i ### Deferred report delivery -The MVP exposes no `report` tool and provides no child-to-parent content delivery or automatic parent wakeup. The durable child Session remains the source of the child's detailed output. +This version exposes no `report` tool and provides no child-to-parent content delivery or automatic parent wakeup. The durable child Session remains the source of the child's detailed output. A later proposal may add an ordinary model-facing `report(output)` tool that can be called zero or multiple times in one turn. Its delivery policy may distinguish quiet parent injection from waking the parent; recipient selection, acknowledgement, durability, and retry semantics are deferred with that tool. Adding report delivery does not require another Activation state or execution queue. ### Deferred steering -The MVP exposes no subagent steering operation. Parent and user continuation messages always open later FIFO turns, so the continuation layer stores no current-turn controller and adds no controller-aware Agent admission seam. +This version exposes no subagent steering operation. Parent and user continuation messages always open later FIFO turns, so the continuation layer stores no current-turn controller and adds no controller-aware Agent admission seam. A later host UI may expose separate **Steer** and **Follow up** actions. User steering would be strict and live-only: it may call the existing Agent steering path only while the Activation accepts a next step, must reject otherwise, and must never fall back to queueing or cold resume. Exposing parent steering to a model-facing tool remains a separate design because distinct tool names express intent but do not establish whether the parent may modify a user-controlled turn. @@ -129,13 +129,13 @@ A later host UI may expose separate **Steer** and **Follow up** actions. User st Authority is supplied by a trusted host interaction or an exact live Agent tool context. `MessageSource` and `senderSessionId` are durable provenance after admission, not caller-controlled authority. -The MVP authorizes the host user and the durable child's direct parent. Parent authorization checks `SessionHeader.parentSession` against the authenticated parent Agent before registering the child in that parent's `ownedChildren`. Other Agents, ancestors, teams, and workflows remain rejected until an explicit authority protocol exists. +This version authorizes the host user and the durable child's direct parent. Parent authorization checks `SessionHeader.parentSession` against the authenticated parent Agent before registering the child in that parent's `ownedChildren`. Other Agents, ancestors, teams, and workflows remain rejected until an explicit authority protocol exists. User authority may cold-resume a child without its parent. Parent-originated delivery requires the parent to be live when admitted and keeps it live through the ownership relationship. ### Durability, disposal, and recovery -Without Tasks there is no `task_output`, `task_kill`, Task status, per-message result promise, or public subagent cancellation operation. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, neither parent nor user can cancel the message, turn, or Activation through `ctx.subagents`; `Agent.cancel()` remains a lower-level Agent capability that this MVP does not expose through the subagent service. +Without Tasks there is no `task_output`, `task_kill`, Task status, per-message result promise, or public subagent cancellation operation. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, neither parent nor user can cancel the message, turn, or Activation through `ctx.subagents`; `Agent.cancel()` remains a lower-level Agent capability that this version does not expose through the subagent service. Host and manager teardown remains the lifecycle-wide stop path. It closes admission, disposes every live Activation forest child-first, and preserves the durable Sessions. @@ -147,9 +147,9 @@ Session and descriptor persistence survive restart. Activation state, Agent inbo ### Scope -The MVP covers continuable in-process children and leaves one-shot delegation unchanged. Remote providers require a separate Activation handle with equivalent authenticated control and child-first quiescence contracts before they can support the same behavior. +This version covers continuable in-process children and leaves one-shot delegation unchanged. Remote providers require a separate Activation handle with equivalent authenticated control and child-first quiescence contracts before they can support the same behavior. -The MVP adds no subagent steering operation, report tool, child-to-parent content delivery, automatic parent wakeup, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public subagent cancellation operation, new live-Activation or descendant limit, or runtime cache. Existing delegation-depth policy remains unchanged. +It adds no subagent steering operation, report tool, child-to-parent content delivery, automatic parent wakeup, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public subagent cancellation operation, new live-Activation or descendant limit, or runtime cache. Existing delegation-depth policy remains unchanged. ## Alternatives considered @@ -159,9 +159,9 @@ The MVP adds no subagent steering operation, report tool, child-to-parent conten **Dispose the Agent while waiting.** Reconstructing a parent while its child still belongs to the previous process-local ownership graph would require a durable ownership and teardown protocol. Retaining the `AgentHandle` only for the unfinished graph preserves child-first teardown without keeping settled history resident. -**Let the provider create, resume, or deliver through an Agent handle.** Initial providers own only `prepareContinuable()` and its detached creation-spec distinction: whether a child begins fresh or with a parent prefix. The manager must call `ctx.agents.create()` through its private activation-owner scope so that scope is a structural owner of every handle. A persisted in-process Session already contains the initial prefix and generic reconstruction descriptor, while delivery belongs to the Agent inbox. Giving providers any later handle, `SubagentRun`, or message ownership would preserve a seam with no MVP behavior to own and would complicate user cold resume with an unnecessary live-parent input. +**Let the provider create, resume, or deliver through an Agent handle.** Initial providers own only `prepareContinuable()` and its detached creation-spec distinction: whether a child begins fresh or with a parent prefix. The manager must call `ctx.agents.create()` through its private activation-owner scope so that scope is a structural owner of every handle. A persisted in-process Session already contains the initial prefix and generic reconstruction descriptor, while delivery belongs to the Agent inbox. Giving providers any later handle, `SubagentRun`, or message ownership would preserve a seam with no shipped behavior to own and would complicate user cold resume with an unnecessary live-parent input. -**Add report delivery to the MVP.** A repeatable model-facing tool is compatible with this lifecycle, but quiet versus waking delivery, recipient selection, acknowledgement, durability, and retry behavior are independent product choices. Deferring the tool keeps the first version focused on conversation admission and residency without constraining that later policy. +**Add report delivery now.** A repeatable model-facing tool is compatible with this lifecycle, but quiet versus waking delivery, recipient selection, acknowledgement, durability, and retry behavior are independent product choices. Deferring the tool keeps the first version focused on conversation admission and residency without constraining that later policy. **Treat `SessionHeader.parentSession` as live ownership.** Durable lineage does not prove that the historical parent currently owns the child. Membership in the live parent's `ownedChildren` records the process-local relationship without changing durable provenance. @@ -169,7 +169,7 @@ The MVP adds no subagent steering operation, report tool, child-to-parent conten **Maintain a separate queue for parent messages.** A second FIFO creates ambiguous ordering against user messages already accepted by the Agent. A single Agent inbox gives both origins one observable order. -**Expose subagent steering in the MVP.** User steering can be a strict live-only host action, but parent steering needs current-turn controller state to protect a user-controlled turn. Queueing every first-version continuation avoids that state and its admission race. A later UI can add a distinct user-only action without changing follow-up ordering. +**Expose subagent steering now.** User steering can be a strict live-only host action, but parent steering needs current-turn controller state to protect a user-controlled turn. Queueing every first-version continuation avoids that state and its admission race. A later UI can add a distinct user-only action without changing follow-up ordering. **Return a subagent-specific delivery route.** Labels such as `started`, `queued`, and `resumed` duplicate Activation and inbox state without giving the caller an independent result. Reusing `MessageId` and the existing inbox events keeps delivery correlation on the Agent contract that owns it. @@ -189,14 +189,14 @@ The implementation pins these behaviors: - `followup()` accepts only trusted parent or user authority; durable message provenance cannot authorize delivery. - Parent and user continuation messages always use `Agent.followup()` and share its inbox FIFO, including when one origin queues behind the other or the child already has an open turn. - `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `MessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result. -- The MVP exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host and manager teardown retains child-first global cleanup. -- The MVP exposes no subagent steering operation or current-turn controller state. +- This version exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host and manager teardown retains child-first global cleanup. +- This version exposes no subagent steering operation or current-turn controller state. - An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained. - A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation. - Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph. - Final Activation settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` and rejection report `DURABILITY_FAILED`, still dispose the child handle, and still release parent ownership so durability failure cannot leak a `waiting` Activation. - Host and manager teardown synchronously enter draining, reject new materialization and delivery, stop manager-owned outward notifications, dispose every snapshotted live Activation forest child-first, await every branch despite individual failures, and only then dispose top-level Agents and the manager scope; a private activation-owner scope preserves this order against Cordis effect unwinding, and one memoized disposal promise per Activation makes concurrent normal settlement idempotent. -- The MVP exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. +- This version exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. - Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. - No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. - Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance failure, caller-signal ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. @@ -207,12 +207,12 @@ The implementation pins these behaviors: Removing Tasks gives up generic background-work inspection, result collection, and exact Task cancellation. If those product features become requirements, they need a request ticket or inbox capability that does not reintroduce a second execution queue. -Retaining an Activation while descendants run consumes Agent resources proportional to the unfinished ownership graph. The existing delegation-depth policy still bounds nesting, but the MVP adds no live-Activation or total-descendant limit; settled historical Sessions retain no `AgentHandle`. +Retaining an Activation while descendants run consumes Agent resources proportional to the unfinished ownership graph. The existing delegation-depth policy still bounds nesting, but this version adds no live-Activation or total-descendant limit; settled historical Sessions retain no `AgentHandle`. The process-local inbox and ownership graph do not coordinate two harness processes. Deployments allowing concurrent access to one persistence store still require a durable lease and mailbox protocol. Without report delivery, completing a child turn neither sends its content to nor wakes the historical parent. The output remains in the durable child Session until a caller inspects that transcript or submits another authorized turn. A later report tool may add quiet or waking delivery without changing the Activation lifecycle. -Queueing every continuation message means a parent cannot correct an in-progress child turn immediately; the correction runs as the next turn. A later user-only UI steering action may reduce that latency without introducing parent-versus-user controller policy into the MVP. +Queueing every continuation message means a parent cannot correct an in-progress child turn immediately; the correction runs as the next turn. A later user-only UI steering action may reduce that latency without introducing parent-versus-user controller policy here. A failed final durability checkpoint allows the runtime ownership graph to drain but leaves the persisted child state missing or stale. The failure is observable as `DURABILITY_FAILED`; retry and repair require a separate recovery design. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index eb14ebcec9..71089cd71a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -4,13 +4,13 @@ Status: implemented [English](2026-07-28-continuable-subagent-conversations.md) | 中文 -本提案将取代[可继续的后台 subagent](../../implemented/feature/2026-07-21-continuable-background-subagents.md)中由 Task 支撑的继续执行管理器。提案保留[将 subagent 控制合并到 subagent 服务](../../implemented/simplification/2026-07-26-merge-subagent-control-service.md)确立的单一 `ctx.subagents` 服务,以及[以意图命名的 subagent 继续执行操作](../../implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md)确立的 `followup` 操作。 +本记录取代[可继续的后台 subagent](../../implemented/feature/2026-07-21-continuable-background-subagents.md)中由 Task 支撑的继续执行管理器。它保留[将 subagent 控制合并到 subagent 服务](../../implemented/simplification/2026-07-26-merge-subagent-control-service.md)确立的单一 `ctx.subagents` 服务,以及[以意图命名的 subagent 继续执行操作](../../implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md)确立的 `followup` 操作。 ## 问题 -继续执行管理器目前让一个 Task、一次提供方执行和一个结果边界共享同一生命周期。Task 结算会 dispose(资源释放)child Agent,Task 完成会注入完成通知,后续输入则重建另一个 Agent。这使通用后台工作抽象与会话投递耦合,而可继续 subagent 已经具备会话和 Agent inbox。 +以前的继续执行管理器让一个 Task、一次提供方执行和一个结果边界共享同一生命周期。Task 结算会 dispose(资源释放)child Agent,Task 完成会注入完成通知,后续输入则重建另一个 Agent。这曾使通用后台工作抽象与会话投递耦合,而可继续 subagent 已经具备会话和 Agent inbox。 -如果继续执行管理器为 parent 请求排队,而 Agent 接收用户消息,系统就会出现两个 FIFO,且没有唯一的顺序权威。如果两种消息都交给 Task,系统又会重复 agent loop(智能体循环)已有的准入、取消和完全停稳机制。`Agent.whenIdle()` 无法恢复单项请求的 Task 结果,因为一个运行区间可能清空多个排队轮次;宽泛的 `Agent.cancel()` 也不能精确移除一项排队请求。 +如果继续执行管理器为 parent 请求排队,而 Agent 接收用户消息,系统就会出现两个 FIFO,且没有唯一的顺序权威。而把两种消息都交给 Task,则重复了 agent loop(智能体循环)已有的准入、取消和完全停稳机制。`Agent.whenIdle()` 无法恢复单项请求的 Task 结果,因为一个运行区间可能清空多个排队轮次;宽泛的 `Agent.cancel()` 也不能精确移除一项排队请求。 运行时生命周期也比单个轮次更长。subagent 可能已经结束自身轮次,但它创建的 child 仍在运行。此时 dispose parent 运行时,会移除仍负责后代拆卸的 Agent。反之,如果让所有历史 subagent 始终驻留,内存使用就会失去上界。 @@ -30,7 +30,7 @@ persisted Session 激活是重建 child Agent 的一次驻留周期。它可以执行多个 FIFO 轮次,并在等待后代时保持驻留。它不是请求、结果、取消或 Task 边界。 -继续执行管理器负责激活准入、权限检查、在线所有权图、冷恢复和 child-first dispose。Agent loop 负责全部轮次排序与执行。本提案不会为可继续 subagent 创建 Task、激活 FIFO 或 queued 激活状态。 +继续执行管理器负责激活准入、权限检查、在线所有权图、冷恢复和 child-first dispose。Agent loop 负责全部轮次排序与执行。没有任何可继续 subagent 拥有 Task、激活 FIFO 或 queued 激活状态。 ### 物化与公开操作 @@ -54,7 +54,7 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的 空闲的历史会话没有 `AgentHandle`。第一条通过鉴权的 `next-turn` 投递会根据持久化会话恢复激活,并将消息提交到其 inbox。经用户授权的冷恢复不会加载历史 parent Agent。parent 发起的恢复使用经过身份认证的确切在线 parent Agent 执行鉴权;当该 parent 有激活时,还使用它建立所有权,但绝不使用 parent 执行重建。 -激活作为消费方会直接持有已发布的 `AgentHandle` 直至结算,而管理器的私有 activation-owner 作用域则是其 Cordis 结构化所有者。可继续 subagent 路径不创建任何中间的带结果执行包装层,包括 `SubagentRun`;一次性委派保持不变,且不属于该生命周期。远程提供方不在 MVP 范围内,引入时需要单独的激活所有权契约。激活 dispose 后,历史会话不消耗运行时内存。 +激活作为消费方会直接持有已发布的 `AgentHandle` 直至结算,而管理器的私有 activation-owner 作用域则是其 Cordis 结构化所有者。可继续 subagent 路径不创建任何中间的带结果执行包装层,包括 `SubagentRun`;一次性委派保持不变,且不属于该生命周期。远程提供方不在此处的范围内,引入时需要单独的激活所有权契约。激活 dispose 后,历史会话不消耗运行时内存。 ### 激活生命周期 @@ -107,7 +107,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 用户冷恢复会创建一次激活,但不会将其加入历史 parent 的 `ownedChildren`。如果直接 parent 随后向这个在线激活提交工作,且该 parent 自身由继续执行管理器管理,准入过程会在消息入队前建立所有权;非继续执行 parent 仍位于等待图之外。 -MVP 会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 +系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain,并涵盖由用户恢复且没有在线 owner 的激活。 @@ -115,13 +115,13 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect ### 延后的报告投递 -MVP 不暴露 `report` 工具,也不提供从 child 到 parent 的内容投递或自动唤醒 parent。持久化 child 会话仍是 child 详细输出的来源。 +本版本不暴露 `report` 工具,也不提供从 child 到 parent 的内容投递或自动唤醒 parent。持久化 child 会话仍是 child 详细输出的来源。 后续提案可以增加一个普通的面向模型 `report(output)` 工具;模型在一个轮次中可以调用它零次或多次。其投递策略可以区分静默注入 parent 与唤醒 parent;接收方选择、确认、持久性和重试语义均与该工具一并延后决定。增加报告投递无需引入另一个激活状态或执行队列。 ### 延后的 steering(中途引导) -MVP 不暴露 subagent steering 操作。parent 和用户的继续执行消息始终开启后续 FIFO 轮次,因此继续执行层不存储当前轮次控制方,也不新增能够感知控制方的 Agent 准入 seam。 +本版本不暴露 subagent steering 操作。parent 和用户的继续执行消息始终开启后续 FIFO 轮次,因此继续执行层不存储当前轮次控制方,也不新增能够感知控制方的 Agent 准入 seam。 后续宿主 UI 可以分别暴露 **Steer** 和 **Follow up** 操作。用户 steering 必须严格且仅限在线使用:只有当激活接受下一步骤时,它才能调用现有的 Agent steering 路径;其他情况必须拒绝,而且绝不能转为排队或冷恢复。是否通过面向模型的工具暴露 parent steering 仍需单独设计,因为不同的工具名称可以表达意图,却不能确定 parent 是否可以修改由用户控制的轮次。 @@ -129,13 +129,13 @@ MVP 不暴露 subagent steering 操作。parent 和用户的继续执行消息 权限来自可信宿主交互或确切的在线 Agent 工具上下文。`MessageSource` 和 `senderSessionId` 是准入后的持久化来源信息,不是由调用方控制的权限。 -MVP 授权宿主用户和持久化 child 的直接 parent。系统会根据经过身份认证的 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、团队和工作流仍被拒绝,直至系统具备显式权限协议。 +本版本授权宿主用户和持久化 child 的直接 parent。系统会根据经过身份认证的 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、团队和工作流仍被拒绝,直至系统具备显式权限协议。 用户权限可以在 parent 不在线时冷恢复 child。由 parent 发起的投递要求 parent 在准入时在线,并通过所有权关系使其继续在线。 ### 持久性、dispose 与恢复 -没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 和用户都不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本 MVP 不通过 subagent 服务暴露它。 +没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 和用户都不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本版本不通过 subagent 服务暴露它。 宿主和管理器拆卸仍是覆盖整个生命周期的停止路径。它会关闭准入,按 child-first 顺序 dispose 每个在线激活森林,并保留持久化会话。 @@ -147,9 +147,9 @@ MVP 授权宿主用户和持久化 child 的直接 parent。系统会根据经 ### 范围 -MVP 覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳契约,才能支持同样的行为。 +本版本覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳契约,才能支持同样的行为。 -MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的内容投递、自动唤醒 parent、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。 +它不新增 subagent steering 操作、报告工具、从 child 到 parent 的内容投递、自动唤醒 parent、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。 ## 曾考虑的替代方案 @@ -159,9 +159,9 @@ MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的 **等待期间 dispose Agent。** child 仍属于上一个进程内所有权图时重建 parent,需要持久化所有权与拆卸协议。只为尚未完成的所有权图保留 `AgentHandle`,可以在不让已结算历史驻留的前提下,保留 child-first 拆卸。 -**让提供方通过 Agent handle 创建、恢复 child 或投递消息。** 初始提供方只持有 `prepareContinuable()` 及其分离式创建规格这一项差异:child 是全新启动,还是带有 parent 前缀。管理器必须通过私有 activation-owner 作用域自行调用 `ctx.agents.create()`,使该作用域成为每个 handle 的结构化所有者。持久化的进程内会话已经包含初始前缀及通用重建描述符,消息投递则属于 Agent inbox。让提供方持有任何后续 handle、`SubagentRun` 或消息所有权,会保留一条没有 MVP 行为可承载的 seam,还会因不必要的在线 parent 输入使用户冷恢复更加复杂。 +**让提供方通过 Agent handle 创建、恢复 child 或投递消息。** 初始提供方只持有 `prepareContinuable()` 及其分离式创建规格这一项差异:child 是全新启动,还是带有 parent 前缀。管理器必须通过私有 activation-owner 作用域自行调用 `ctx.agents.create()`,使该作用域成为每个 handle 的结构化所有者。持久化的进程内会话已经包含初始前缀及通用重建描述符,消息投递则属于 Agent inbox。让提供方持有任何后续 handle、`SubagentRun` 或消息所有权,会保留一条没有已发布行为可承载的 seam,还会因不必要的在线 parent 输入使用户冷恢复更加复杂。 -**在 MVP 中增加报告投递。** 可重复调用的面向模型工具与该生命周期兼容,但静默投递还是唤醒投递、接收方选择、确认、持久性和重试行为都是独立的产品决策。延后该工具,可以让首个版本专注于会话准入与驻留,又不限制后续策略。 +**现在就增加报告投递。** 可重复调用的面向模型工具与该生命周期兼容,但静默投递还是唤醒投递、接收方选择、确认、持久性和重试行为都是独立的产品决策。延后该工具,可以让首个版本专注于会话准入与驻留,又不限制后续策略。 **将 `SessionHeader.parentSession` 视为在线所有权。** 持久化谱系不能证明历史 parent 当前持有 child。在线 parent 的 `ownedChildren` 成员关系会记录进程内关系,而不改变持久化来源。 @@ -169,7 +169,7 @@ MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的 **为 parent 消息维护单独队列。** 第二个 FIFO 会让它和 Agent 已接受的用户消息之间顺序不明确。单个 Agent inbox 为两种来源提供唯一且可观察的顺序。 -**在 MVP 中暴露 subagent steering。** 用户 steering 可以是严格且仅限在线使用的宿主操作,但 parent steering 需要当前轮次控制方状态,以保护由用户控制的轮次。首个版本将每条继续执行消息都排队,可以避免引入该状态及其准入竞争。后续 UI 可以新增一项仅限用户的独立操作,而不改变 follow-up 排序。 +**现在就暴露 subagent steering。** 用户 steering 可以是严格且仅限在线使用的宿主操作,但 parent steering 需要当前轮次控制方状态,以保护由用户控制的轮次。首个版本将每条继续执行消息都排队,可以避免引入该状态及其准入竞争。后续 UI 可以新增一项仅限用户的独立操作,而不改变 follow-up 排序。 **返回 subagent 专属的投递路由。** `started`、`queued` 和 `resumed` 等标签重复了激活与 inbox 状态,却没有给调用方提供独立结果。复用 `MessageId` 和现有 inbox 事件,可以让投递关联继续由其所属的 Agent 契约承载。 @@ -189,14 +189,14 @@ MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的 - `followup()` 只接受可信 parent 或用户权限;持久化消息来源信息不能授权投递。 - Parent 和用户的继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括一种来源排在另一种来源之后,以及 child 已有开放轮次的情况。 - `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 -- MVP 不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 -- MVP 不暴露 subagent steering 操作或当前轮次控制方状态。 +- 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 +- 本版本不暴露 subagent steering 操作或当前轮次控制方状态。 - 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。 - 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。 - 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。 - 激活最终结算时,只有 `ctx.sessions.flush(child.session) === true` 才确认持久性;`false` 和 rejection 会报告 `DURABILITY_FAILED`,但仍会 dispose child handle 并释放 parent 所有权,使持久性失败不会泄漏 `waiting` 激活。 - 宿主和管理器拆卸会同步进入 draining,拒绝新的物化和投递,停止由管理器负责的对外通知,按 child-first 顺序 dispose 处于快照中的整个在线激活森林,即使个别分支失败也会等待所有分支,之后才 dispose 顶层 Agent 和管理器作用域;私有 activation-owner 作用域会确保 Cordis effect 的逆序撤销不破坏该顺序,每次激活使用一个记忆化的 dispose promise,使并发的正常结算保持幂等。 -- MVP 不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 +- 本版本不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 - 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 - 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 - 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前失败路径的完整回滚、接受前后两个阶段的调用方 signal 所有权,以及已接受但未写入日志的消息不会自动回放。 @@ -207,12 +207,12 @@ MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的 移除 Task 会放弃通用后台工作检查、结果收集和精确 Task 取消。如果这些产品功能成为需求,就需要不会重新引入第二条执行队列的请求 ticket 或 inbox 能力。 -在后代运行期间保留激活,会按尚未完成所有权图的规模消耗 Agent 资源。现有委派深度策略仍会限制嵌套层级,但 MVP 不新增在线激活数量或后代总数限制;已结算的历史会话不保留 `AgentHandle`。 +在后代运行期间保留激活,会按尚未完成所有权图的规模消耗 Agent 资源。现有委派深度策略仍会限制嵌套层级,但本版本不新增在线激活数量或后代总数限制;已结算的历史会话不保留 `AgentHandle`。 进程内 inbox 和所有权图无法协调两个 harness 进程。允许多个进程并发访问同一持久化存储的部署,仍需要持久化 lease 和邮箱协议。 没有报告投递时,完成 child 轮次既不会把内容发送给历史 parent,也不会唤醒它。输出会保留在持久化 child 会话中,直至调用方检查该 transcript 或提交另一个经过授权的轮次。后续报告工具可以增加静默投递或唤醒投递,而无需改变激活生命周期。 -将每条继续执行消息排队,意味着 parent 无法立即纠正正在进行的 child 轮次;纠正操作会在下一个轮次执行。后续仅限用户的 UI steering 操作可以缩短该延迟,而无需在 MVP 中引入 parent 与用户之间的控制方策略。 +将每条继续执行消息排队,意味着 parent 无法立即纠正正在进行的 child 轮次;纠正操作会在下一个轮次执行。后续仅限用户的 UI steering 操作可以缩短该延迟,而无需在此引入 parent 与用户之间的控制方策略。 最终持久性检查点失败时,运行时所有权图仍可完成 drain,但持久化 child 状态会缺失或陈旧。该失败会以 `DURABILITY_FAILED` 的形式被观测到;重试与修复需要单独的恢复设计。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4d08e8221a..19302ab38e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -794,7 +794,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:141`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:143`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -811,7 +811,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:115`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:117`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -826,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:121`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -848,7 +848,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:132`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6e8ab17396..d8c55b94d1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1980,6 +1980,15 @@ async startContinuable(spec: ContinuableStartSpec): Promise */ async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise +/** + * Host-user authority for continuable operations, which may continue any + * durable child without its parent. A composition passes this only to a + * trusted host adapter carrying real human interaction; a model-facing tool + * uses `{ kind: 'parent', agent }` from its own execution context instead. + * @returns the authority a host adapter supplies to {@link followup}. + */ +userAuthority(): SubagentAuthority + /** * Read one durable child's live residency state. * @param childId - durable child session id. @@ -2033,7 +2042,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [ActivationState](../core-data-structures/subagent.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentAuthority](../core-data-structures/subagent.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:174`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:176`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index d655798990..3a8cd50e70 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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/subagent.md -subagent.md: a58ecf13ba1f5df0e8e35c793eaf9aefc1e8a900 -subagent.zh.md: 541eace7fc6c8ae10ee22639680918e12d7762b3 +subagent.md: ceff3586bf6724bd6f47b71e9fb737361a2830f8 +subagent.zh.md: aa39ea382fe1e2a52b6ee794cfa71d8abc945da3 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index a58ecf13ba..ceff3586bf 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -123,7 +123,7 @@ persisted Session The Agent inbox is the only queue. Every continuation message becomes one `Agent.followup()` FIFO turn, so parent and user messages share one observable order and a follow-up cannot redirect a turn already underway. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/enqueue`, `agent/inbox/dequeue`, and `agent/inbox/discard` events remain the message-lifecycle observations, and the continuation layer defines no subagent-specific delivery route. -Authority is supplied by a trusted host interaction or an exact live Agent tool context. The parent variant is admitted only when the authenticated Agent is the durable child's direct parent recorded in `SessionHeader.parentSession`; only a trusted host adapter can supply user authority. `MessageSource` and `senderSessionId` are durable provenance after admission and grant no authority — the optional model-facing tool uses `CoordinatorMessageSource`, while a host adapter uses `{ kind: 'user' }`. User authority may cold-resume a child without loading its historical parent. +Authority is supplied by a trusted host interaction or an exact live Agent tool context. The parent variant is admitted only when the authenticated Agent is the durable child's direct parent recorded in `SessionHeader.parentSession`. User authority carries an opaque grant that only `SubagentService.userAuthority()` mints, so a caller cannot claim it by writing the discriminant — a plugin holding `ctx.subagents`, including model-generated mount code, would otherwise bypass the direct-parent check for any known child id. `MessageSource` and `senderSessionId` are durable provenance after admission and grant no authority — the optional model-facing tool uses `CoordinatorMessageSource`, while a host adapter uses `{ kind: 'user' }`. User authority may cold-resume a child without loading its historical parent. For both operations the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child, and the seam exposes no public subagent cancellation or steering operation. @@ -149,8 +149,14 @@ interface CoordinatorMessageSource { type SubagentAuthority = /** The exact live parent Agent whose tool context is making the call. */ | { readonly kind: 'parent'; readonly agent: Agent } - /** A trusted host adapter acting for the human user. */ - | { readonly kind: 'user' } + /** + * A trusted host adapter acting for the human user. The `grant` must be the + * exact token {@link SubagentService.userAuthority} minted, so a discriminant + * alone cannot claim this authority — any plugin holding `ctx.subagents`, + * including model-generated mount code, could otherwise forge it and bypass + * the direct-parent check. + */ + | { readonly kind: 'user'; readonly grant: UserAuthorityGrant } ``` ```ts type-equiv diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 541eace7fc..aa39ea382f 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -149,8 +149,14 @@ interface CoordinatorMessageSource { type SubagentAuthority = /** The exact live parent Agent whose tool context is making the call. */ | { readonly kind: 'parent'; readonly agent: Agent } - /** A trusted host adapter acting for the human user. */ - | { readonly kind: 'user' } + /** + * A trusted host adapter acting for the human user. The `grant` must be the + * exact token {@link SubagentService.userAuthority} minted, so a discriminant + * alone cannot claim this authority — any plugin holding `ctx.subagents`, + * including model-generated mount code, could otherwise forge it and bypass + * the direct-parent check. + */ + | { readonly kind: 'user'; readonly grant: UserAuthorityGrant } ``` ```ts type-equiv diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d41a6b329b..ce2e06088a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:141`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:115`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:121`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:132`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:117`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:123`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index f3a3f87e3a..5412001611 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1151,7 +1151,7 @@ The registered tool name is the load-time `toolName` config (default `subagent`) ### `send_message` -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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered. +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. ```json { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index b1c44a10ea..25aa600858 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -110,7 +110,7 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** 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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered. */ + /** 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. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; @@ -122,22 +122,22 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** 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 to you, so read its transcript by that id, or send it more work with `send_message`. */ + /** 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. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ + /** 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. */ run_in_background?: boolean; } & Record; - /** 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 to you, so read its transcript by that id, or send it more work with `send_message`. */ + /** 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. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ + /** 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. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 7863590b88..0ab66be027 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -239,7 +239,7 @@ }, { "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "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": { @@ -276,7 +276,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -290,7 +290,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ @@ -301,7 +301,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -315,7 +315,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 585c601a84..6f0dfc2aab 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -182,7 +182,7 @@ }, { "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "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": { @@ -219,7 +219,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -233,7 +233,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ @@ -244,7 +244,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -258,7 +258,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index e53e20d3ad..7cb77234c2 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -93,7 +93,7 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** 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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered. */ + /** 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. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; @@ -105,22 +105,22 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** 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 to you, so read its transcript by that id, or send it more work with `send_message`. */ + /** 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. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ + /** 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. */ run_in_background?: boolean; } & Record; - /** 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 to you, so read its transcript by that id, or send it more work with `send_message`. */ + /** 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. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ + /** 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. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index dd4be5f915..6248d0449f 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -198,7 +198,7 @@ }, { "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "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": { @@ -235,7 +235,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -249,7 +249,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ @@ -260,7 +260,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -274,7 +274,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index d80fe4b555..539c0514fd 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "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": { @@ -198,7 +198,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ @@ -223,7 +223,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index 59bb91d9ac..476cb70ceb 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "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": { @@ -402,7 +402,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -416,7 +416,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ @@ -427,7 +427,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -441,7 +441,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index ee1c0e158b..c6c3c9ee92 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "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": { @@ -198,7 +198,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ @@ -223,7 +223,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index c94b51630d..f61d20859d 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "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 — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "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": { @@ -198,7 +198,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ @@ -223,7 +223,7 @@ }, { "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 to you, so read its transcript by that id, or send it more work with `send_message`.", + "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": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "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": [ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0cf46786e3..210024259f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -892,6 +892,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so parent and user messages\n * share one observable order.\n * @param authority - trusted parent or user authority for this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, authority is rejected,\n * or the message was not admitted.\n */', }, + { + signature: 'userAuthority(): SubagentAuthority', + jsDoc: '/**\n * Host-user authority for continuable operations, which may continue any\n * durable child without its parent. A composition passes this only to a\n * trusted host adapter carrying real human interaction; a model-facing tool\n * uses `{ kind: \'parent\', agent }` from its own execution context instead.\n * @returns the authority a host adapter supplies to {@link followup}.\n */', + }, { signature: 'activationState(childId: SessionId): ActivationState | undefined', jsDoc: '/**\n * Read one durable child\'s live residency state.\n * @param childId - durable child session id.\n * @returns its Activation state, or `undefined` when no Activation is live.\n * @throws when continuation services are unavailable.\n */', @@ -2693,7 +2697,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentAuthority', - declaration: 'export type SubagentAuthority = {\n readonly kind: \'parent\';\n readonly agent: Agent;\n} | {\n readonly kind: \'user\';\n};', + declaration: 'export type SubagentAuthority = {\n readonly kind: \'parent\';\n readonly agent: Agent;\n} | {\n readonly kind: \'user\';\n readonly grant: UserAuthorityGrant;\n};', }, { name: 'SubagentCapabilities', @@ -3035,6 +3039,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertTypeModel', declaration: 'export interface TypertTypeModel {\n readonly name: string;\n readonly declaration: string;\n}', }, + { + name: 'UserAuthorityGrant', + declaration: 'export type UserAuthorityGrant = {\n readonly __brand: \'SubagentUserAuthority\';\n};', + }, { name: 'UserInteractionProvider', declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise;\n}', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index c906868e1d..9ddbf4f465 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: fc1eecb7d22c45377d5525ef0247bcf369a441a8 -README.zh.md: 762a027324bc40f159129c3cd4a438d2265fa32b +README.md: 6a8016dc71d928c1770cc0769f99d2cb53c6b035 +README.zh.md: 53f553bd2747bebac0f2d42ac80ad8b6eb660c45 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index fc1eecb7d2..6a8016dc71 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -31,12 +31,13 @@ Multiple providers may coexist under different names. This lets a deployment exp | `start(name, request)` | Validate an ordinary caller request, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. | | `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. | | `followup(authority, childId, content, { source, signal })` | Deliver one later message to a continuable child as its next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `AgentMessageId`. A resident child's inbox accepts it directly (waking a `waiting` Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. | +| `userAuthority()` | Mint the host-user authority a trusted adapter passes to `followup()`. Composition hands this only to a host carrying real human interaction; a model-facing tool uses its own `{ kind: 'parent', agent }` instead. | | `activationState(childId)` | Read one durable child's live residency state (`running`, `waiting`, or `settled`), or `undefined` when no Activation is live. | | `drainContinuable()` | Close continuable admission synchronously, then dispose every live Activation forest child-first. A host calls this before disposing top-level agents so no descendant outlives the runtime that owns its teardown. An aggregate error surfaces after every branch settles when any failed. | `SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child. -Authority for continuable operations comes from a trusted host interaction or an exact live Agent tool context: `SubagentAuthority` is `{ kind: 'parent', agent }` or `{ kind: 'user' }`. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority. Parent authority requires the exact live direct parent recorded in the child's durable header; user authority may continue any child, and may cold-resume it without loading its historical parent. +Authority for continuable operations comes from a trusted host interaction or an exact live Agent tool context: `SubagentAuthority` is `{ kind: 'parent', agent }` or `{ kind: 'user', grant }`, whose grant only `userAuthority()` mints so the discriminant alone cannot claim it. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority. Parent authority requires the exact live direct parent recorded in the child's durable header; user authority may continue any child, and may cold-resume it without loading its historical parent. Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 762a027324..53f553bd27 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -31,12 +31,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 | | `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | | `followup(authority, childId, content, { source, signal })` | 将一条后续消息作为可继续子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `AgentMessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 `waiting` 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | +| `userAuthority()` | 铸造可信 host 适配器传给 `followup()` 的 host 用户权限。组合装配仅将其交给承载真实人类交互的 host;面向模型的工具改用自身执行上下文的 `{ kind: 'parent', agent }`。 | | `activationState(childId)` | 读取某个持久化子 agent 的实时驻留状态(`running`、`waiting` 或 `settled`);无实时 Activation 时返回 `undefined`。 | | `drainContinuable()` | 同步关闭可继续准入,然后以子先于父的顺序 dispose 每一个实时 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 | `SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。 -可继续操作的权限来自可信的 host 交互或准确的实时 Agent 工具上下文:`SubagentAuthority` 为 `{ kind: 'parent', agent }` 或 `{ kind: 'user' }`。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。父级权限要求准确匹配子 agent 持久化 header 中记录的实时直接父级;用户权限可以继续任何子 agent,并且可以在不加载其历史父级的情况下将其冷恢复。 +可继续操作的权限来自可信的 host 交互或准确的实时 Agent 工具上下文:`SubagentAuthority` 为 `{ kind: 'parent', agent }` 或 `{ kind: 'user', grant }`——其 grant 仅由 `userAuthority()` 铸造,因此仅凭判别式无法声明该权限。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。父级权限要求准确匹配子 agent 持久化 header 中记录的实时直接父级;用户权限可以继续任何子 agent,并且可以在不加载其历史父级的情况下将其冷恢复。 同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 9974a355a4..632c546bd8 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -61,8 +61,20 @@ declare module '@deepseek-ai/dsh-llm' { export type SubagentAuthority = /** The exact live parent Agent whose tool context is making the call. */ | { readonly kind: 'parent'; readonly agent: Agent } - /** A trusted host adapter acting for the human user. */ - | { readonly kind: 'user' } + /** + * A trusted host adapter acting for the human user. The `grant` must be the + * exact token {@link SubagentService.userAuthority} minted, so a discriminant + * alone cannot claim this authority — any plugin holding `ctx.subagents`, + * including model-generated mount code, could otherwise forge it and bypass + * the direct-parent check. + */ + | { readonly kind: 'user'; readonly grant: UserAuthorityGrant } + +/** + * Opaque proof that a caller obtained user authority from the service rather + * than constructing it. Only {@link SubagentService.userAuthority} mints one. + */ +export type UserAuthorityGrant = { readonly __brand: 'SubagentUserAuthority' } /** What a caller asks for when starting a continuable background child. */ export interface ContinuableStartSpec { @@ -245,6 +257,8 @@ export class SubagentContinuationManager { constructor( private readonly ctx: Context, private readonly host: ContinuationHost, + /** The single token that proves host-user authority for this manager. */ + private readonly userGrant: UserAuthorityGrant, ) { // Ordinary Cordis owner effects unwind in reverse registration order, which // cannot express the dynamic child graph. Register the private scope's @@ -325,6 +339,10 @@ export class SubagentContinuationManager { composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, }) + // Materialization published the Activation; an abort landing in that + // window — a `subagent/start` listener can cancel synchronously — must + // roll the child back instead of opening its first turn. + await this.rollbackIfAborted(activation, spec.signal) return this.submit(activation, request.prompt, { kind: 'user' }, { kind: 'parent', agent: parent }) }) return { childId, messageId } @@ -494,9 +512,25 @@ export class SubagentContinuationManager { composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, signal: options.signal, }) + await this.rollbackIfAborted(activation, options.signal) return this.submit(activation, content, options.source, authority) } + /** + * Dispose a freshly materialized Activation when the caller signal won the + * handoff between publication and inbox acceptance, so an aborted operation + * never leaves a resident child. + * @param activation - the just-published Activation. + * @param signal - the caller signal owning admission until acceptance. + */ + private async rollbackIfAborted(activation: Activation, signal: AbortSignal): Promise { + if (!signal.aborted) return + /* v8 ignore next -- the swallow only covers a disposal fault during rollback, which + * must not mask the caller's abort as the operation's failure. */ + await this.dispose(activation).catch(() => undefined) + signal.throwIfAborted() + } + /** * Create or resume the child Agent through the private activation-owner * scope, install the handle in a fresh Activation, and register ownership on @@ -686,7 +720,17 @@ export class SubagentContinuationManager { childId: SessionId, parentSession: SessionId | undefined, ): void { - if (authority.kind === 'user') return + if (authority.kind === 'user') { + // Identity, not shape: a forged discriminant must not skip the + // direct-parent check for an arbitrary known child id. + if (authority.grant !== this.userGrant) { + throw new SubagentError( + `subagent "${childId}" delivery presented an invalid user-authority grant`, + 'UNAUTHORIZED', + ) + } + return + } const parent = authority.agent if (this.ctx.agents.get(parent.id) !== parent) { throw new SubagentError( diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index a5e120c860..6cc8c0fbe7 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -54,6 +54,7 @@ import SubagentContinuationManager from './continuation.ts' import type { ActivationObserver, ActivationState, + UserAuthorityGrant, ContinuableStart, ContinuableStartSpec, SubagentAuthority, @@ -94,6 +95,7 @@ export type { ChildComposition } from './child-agent.ts' export type { ActivationObserver, ActivationState, + UserAuthorityGrant, ContinuableStart, ContinuableStartSpec, CoordinatorMessageSource, @@ -174,6 +176,15 @@ export interface SubagentRunEndInfo { export class SubagentService extends Service { private providers = new Map() private continuations: SubagentContinuationManager | undefined + /** + * The process-local proof of host-user authority. Minted here so the value is + * unguessable and unforgeable: a caller must obtain it from + * {@link userAuthority}, which composition hands only to trusted host + * adapters. + */ + private readonly userGrant = Object.freeze({ + __brand: 'SubagentUserAuthority', + }) as UserAuthorityGrant constructor(ctx: Context) { super(ctx, 'subagents') @@ -181,7 +192,7 @@ export class SubagentService extends Service { const manager = new SubagentContinuationManager(childCtx, { prepareContinuable: (name, request) => this.prepareContinuable(name, request), observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent), - }) + }, this.userGrant) this.continuations = manager childCtx.effect(() => () => { /* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */ @@ -227,6 +238,17 @@ export class SubagentService extends Service { return this.requireContinuations().followup(authority, childId, content, options) } + /** + * Host-user authority for continuable operations, which may continue any + * durable child without its parent. A composition passes this only to a + * trusted host adapter carrying real human interaction; a model-facing tool + * uses `{ kind: 'parent', agent }` from its own execution context instead. + * @returns the authority a host adapter supplies to {@link followup}. + */ + userAuthority(): SubagentAuthority { + return { kind: 'user', grant: this.userGrant } + } + /** * Read one durable child's live residency state. * @param childId - durable child session id. diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index c20cc175a9..47373046f3 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -213,6 +213,22 @@ describe('SubagentService.startContinuable', () => { }) }) + it('rolls the child back when the signal aborts between publication and acceptance', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + const controller = new AbortController() + // `subagent/start` fires once the epoch is resident, before the prompt is + // submitted, so cancelling here lands squarely in the handoff window. + ctx.on('subagent/start', () => { controller.abort('caller gave up') }) + + await expect(ctx.subagents.startContinuable(startSpec(parent, 'spawn', controller.signal))) + .rejects.toThrow() + + // No resident child and no queued turn survive the abort. + await vi.waitFor(() => { + expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) + }) + }) + it('rejects a continuable child that would exceed the configured depth cap', async () => { const { ctx, parent } = await setup([]) await expect(ctx.subagents.startContinuable({ @@ -287,7 +303,7 @@ describe('SubagentService.startContinuable', () => { await fresh.plugin(AgentLoop, { agents: [] }) await fresh.plugin(SubagentService) await fresh.plugin(SubagentSpawn, { providerName: 'spawn' }) - await followup(fresh, { kind: 'user' }, started.childId, message('resume routeless')) + await followup(fresh, fresh.subagents.userAuthority(), started.childId, message('resume routeless')) const resumed = await vi.waitFor(() => { const found = fresh.agents.get(started.childId) @@ -337,7 +353,7 @@ describe('SubagentService.startContinuable', () => { expect(descriptor?.data).toMatchObject({ persona: 'You are scoped.' }) // Cold resume reconstructs the declared composition from that descriptor. - await followup(ctx, { kind: 'user' }, started.childId, message('resume it')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('resume it')) await waitNoActivation(ctx, started.childId) const resumed = await ctx.sessionPersistence.load(started.childId) expect(hasUserText(resumed.events, 'resume it')).toBe(true) @@ -360,7 +376,7 @@ describe('SubagentService.followup residency routing', () => { // Both origins queue behind the open turn, in call order. const parentMessage = await followup(ctx, { kind: 'parent', agent: parent }, started.childId, message('from parent')) - const userMessage = await followup(ctx, { kind: 'user' }, started.childId, message('from user')) + const userMessage = await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('from user')) expect(parentMessage).not.toBe(userMessage) // Still the same Activation: no second child Agent was created. expect(ctx.agents.get(started.childId)).toBe(child) @@ -376,7 +392,7 @@ describe('SubagentService.followup residency routing', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) - const messageId = await followup(ctx, { kind: 'user' }, started.childId, message('continue please')) + const messageId = await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('continue please')) expect(messageId).toBeTypeOf('string') await waitNoActivation(ctx, started.childId) @@ -410,7 +426,7 @@ describe('SubagentService.followup residency routing', () => { // Waiting retains the handle: the same Agent is still live. expect(ctx.agents.get(started.childId)).toBe(child) - await followup(ctx, { kind: 'user' }, started.childId, message('while waiting')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('while waiting')) // Woken back to running on the SAME Activation. expect(ctx.agents.get(started.childId)).toBe(child) @@ -421,6 +437,23 @@ describe('SubagentService.followup residency routing', () => { expect(userTexts(loaded.events)).toEqual(['child task', 'while waiting']) }) + it('rejects a forged user-authority grant', async () => { + const { ctx, parent } = await setup([textResponse('first')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + // Any plugin holding `ctx.subagents` can write this shape, so shape alone + // must not skip the direct-parent check for an arbitrary known child id. + const forged = { kind: 'user', grant: { __brand: 'SubagentUserAuthority' } } as unknown as SubagentAuthority + await expect(followup(ctx, forged, started.childId, message('not really the user'))) + .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + + // The service-minted grant is accepted. + await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('really the user'))) + .resolves.toBeTypeOf('string') + await waitNoActivation(ctx, started.childId) + }) + it('rejects a parent that is not the durable direct parent', async () => { const { ctx, parent } = await setup([textResponse('first')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -447,7 +480,7 @@ describe('SubagentService.followup residency routing', () => { fresh.llm.registerAdapter(['mock'], new MockAdapter([textResponse('resumed cold')])) expect(fresh.agents.get(SessionId('parent'))).toBeUndefined() - await followup(fresh, { kind: 'user' }, started.childId, message('user continues')) + await followup(fresh, fresh.subagents.userAuthority(), started.childId, message('user continues')) await waitNoActivation(fresh, started.childId) const loaded = await fresh.sessionPersistence.load(started.childId) @@ -469,13 +502,13 @@ describe('SubagentService.followup residency routing', () => { const oneShotId = run.id await run.dispose() - await expect(followup(ctx, { kind: 'user' }, oneShotId, message('continue'))) + await expect(followup(ctx, ctx.subagents.userAuthority(), oneShotId, message('continue'))) .rejects.toThrow(/no supported continuation state/) }) it('reports an unknown child id as unavailable', async () => { const { ctx } = await setup([]) - await expect(followup(ctx, { kind: 'user' }, SessionId('missing'), message('hello'))) + await expect(followup(ctx, ctx.subagents.userAuthority(), SessionId('missing'), message('hello'))) .rejects.toMatchObject({ code: 'NOT_RESUMABLE' }) }) @@ -491,7 +524,7 @@ describe('SubagentService.followup residency routing', () => { // exactly one side wins the cutoff. A delivery that loses awaits release and // cold-resumes rather than reaching a handle being torn down. const delivery = child.whenIdle().then(() => - followup(ctx, { kind: 'user' }, started.childId, message('raced'))) + followup(ctx, ctx.subagents.userAuthority(), started.childId, message('raced'))) await expect(delivery).resolves.toBeTypeOf('string') await waitNoActivation(ctx, started.childId) @@ -619,7 +652,7 @@ describe('continuable durability and teardown', () => { await expect(ctx.subagents.startContinuable(startSpec(parent))) .rejects.toMatchObject({ code: 'DRAINING' }) - await expect(followup(ctx, { kind: 'user' }, started.childId, message('too late'))) + await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('too late'))) .rejects.toMatchObject({ code: 'DRAINING' }) }) @@ -630,7 +663,7 @@ describe('continuable durability and teardown', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) // Accepted into the inbox, but this queued turn never opens. - await followup(ctx, { kind: 'user' }, started.childId, message('never logged')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('never logged')) const drained = ctx.subagents.drainContinuable() hold.resolve(undefined) @@ -674,7 +707,7 @@ describe('continuable review regressions', () => { const controller = new AbortController() controller.abort('caller gave up') - await expect(followup(ctx, { kind: 'user' }, started.childId, message('cancelled'), controller.signal)) + await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('cancelled'), controller.signal)) .rejects.toThrow() // Nothing was enqueued, so no later turn can carry it. @@ -699,7 +732,7 @@ describe('continuable review regressions', () => { // A cold resume is a new epoch: it must report its OWN answer, never the // previous epoch's, which the replayed transcript still contains. - await followup(ctx, { kind: 'user' }, started.childId, message('again')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again')) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(2) }) expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }]) @@ -717,7 +750,7 @@ describe('continuable review regressions', () => { if (subject === parent) return next() return { kind: 'block', reason: 'blocked by policy' } }) - await followup(ctx, { kind: 'user' }, started.childId, message('again')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again')) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(1) }) @@ -786,7 +819,7 @@ describe('continuable review regressions', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) // Queue a turn, then cancel so it is discarded rather than dequeued. The // Activation must still reach settlement instead of waiting on that id. - await followup(ctx, { kind: 'user' }, started.childId, message('discarded')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('discarded')) const drained = ctx.subagents.drainContinuable() hold.resolve(undefined) @@ -797,6 +830,32 @@ describe('continuable review regressions', () => { expect(hasUserText(loaded.events, 'discarded')).toBe(false) }) + it('settles after a delivery discarded inside its own admission window', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + + // Cancel from the synchronous enqueue observer: the discard fires before + // `followup()` returns, so the id is discarded before it can be recorded. + const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => { + if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { + child.cancel({ kind: 'user' }) + } + }) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('doomed')) + off() + + releaseFirst.resolve(undefined) + // Retaining the discarded id would pin residency at `running` forever, so + // reaching no-Activation without an explicit drain is the assertion. + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'doomed')).toBe(false) + }) + it('reports completed when no ordinary turn closed', async () => { const { ctx, parent } = await setup([]) const ends: SubagentRunEndInfo[] = [] @@ -832,7 +891,7 @@ describe('continuable review regressions', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - await followup(ctx, { kind: 'user' }, started.childId, message('queued')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('queued')) expect(states.length).toBeGreaterThan(0) expect(states).not.toContain('settled') @@ -854,7 +913,7 @@ describe('continuable lifecycle observation', () => { await vi.waitFor(() => { expect(ends).toHaveLength(1) }) // A cold resume is a NEW epoch with its own pair. - await followup(ctx, { kind: 'user' }, started.childId, message('again')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again')) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(2) }) @@ -898,7 +957,7 @@ describe('continuable public surface', () => { const controller = new AbortController() controller.abort('caller gave up') - await expect(followup(ctx, { kind: 'user' }, started.childId, message('aborted'), controller.signal)) + await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('aborted'), controller.signal)) .rejects.toThrow() const loaded = await ctx.sessionPersistence.load(started.childId) @@ -916,7 +975,7 @@ describe('continuable public surface', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const controller = new AbortController() - await followup(ctx, { kind: 'user' }, started.childId, message('survives'), controller.signal) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('survives'), controller.signal) // After acceptance the manager owns the Activation independently. controller.abort('caller gave up') @@ -945,7 +1004,7 @@ describe('continuable errors', () => { }).continuations manager.activations.delete(started.childId) - await expect(followup(ctx, { kind: 'user' }, started.childId, message('hello'))) + await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('hello'))) .rejects.toThrow(SubagentError) expect(ctx.agents.get(started.childId)).toBe(child) hold.resolve(undefined) @@ -1068,7 +1127,7 @@ describe('continuable errors', () => { .toMatchObject({ agentProvider: 'mock', agentModel: 'child-model' }) // The resumed Activation runs on the declared route, not the parent's. - await followup(ctx, { kind: 'user' }, started.childId, message('again')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again')) await vi.waitFor(() => { expect(ctx.agents.get(started.childId)?.options.model).toBe('child-model') }) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 2260aaf119..78d45eea47 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -133,7 +133,7 @@ describe('SubagentService', () => { signal: new AbortController().signal, })).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) await expect(subagents.followup( - { kind: 'user' }, + subagents.userAuthority(), SessionId('child'), [{ type: 'text', text: 'hello' }], { source: { kind: 'user' }, signal: new AbortController().signal }, diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 1fbbc3d3fb..e8d2b1d4a1 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -26,8 +26,8 @@ export function apply(ctx: Context): void { 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 — read its ' - + 'transcript by its id to see what it did. A failure means the message was NOT delivered.', + + '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: { subagent_id: { type: 'string', diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 62fef8c055..e163f2e891 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -207,8 +207,8 @@ export function apply(ctx: Context, config: Config): void { description: wording.description + (backgroundEnabled ? continuable ? ' 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 to you, so read' - + ' its transcript by that id, or send it more work with `send_message`.' + + ' 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.' : ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' : ''), parameters: { @@ -226,8 +226,8 @@ export function apply(ctx: Context, config: Config): void { run_in_background: { type: 'boolean' as const, description: continuable - ? 'Run as a background subagent that keeps its conversation and return its subagent id; ' - + 'send it more work with send_message.' + ? '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.' : 'Run as a background task and return its id; collect with task_output or stop with task_kill.', }, } : {}, From 853f4d5cfbd7dd2cb54626556f8f6c80fae37e18 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 17:47:48 +0800 Subject: [PATCH 067/114] refactor(subagent): drop host-user authority and split lifecycle publication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the host-user continuation capability and the public residency query, then separate the seam's public event payloads from its internal lifecycle control interfaces. `followup()` now takes the exact live direct parent `Agent` instead of a `SubagentAuthority` union. No production adapter ever supplied user authority, so the `UserAuthorityGrant` brand token existed only to stop a forged discriminant from bypassing the direct-parent check — deleting the branch retires the token, its mint method, and that attack surface together. Narrowing `parent` from `Agent | undefined` to `Agent` removes three special cases, including the path where a parentless epoch dispatched its lifecycle events unscoped. Scoped-versus-global dispatch is now decided by the event, not by whether a caller happened to have a parent. `activationState()` had no caller; `ActivationState`, `ActivationObserver`, and `ContinuationHost` are package-private. New `src/lifecycle.ts` owns the contained emitter, the one-shot run observer, and the Activation observer, while `SubagentRunInfo`/`SubagentRunEndInfo` move to `src/types.ts` beside the other consumer-facing contracts. Those payloads are public API — dsh-jsonrpc, hooks-claude, and the package invariant all consume them — whereas the observer is a contract between two in-package collaborators, so they no longer share a home merely for both being lifecycle-shaped. The service keeps ownership of the scope carrier: `scopeTarget()` composes the service's own context filter, so a narrowed stand-in would silently change scope filtering. Also drops now-unused dsh-tasks-local and dsh-tool-tasks dev dependencies, and corrects the README claim that a pre-residency failure emits a terminal edge — that path only ever rethrew. --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 57 ++-- ...8-continuable-subagent-conversations.zh.md | 57 ++-- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 33 +-- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 49 +--- docs/core-data-structures/subagent.zh.md | 49 +--- docs/event-producer-consumer.md | 8 +- .../cordis/tool-cordis/src/api-catalog.ts | 24 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 17 +- packages/subagent/subagent/README.zh.md | 17 +- .../subagent/subagent/src/continuation.ts | 140 +++------- packages/subagent/subagent/src/index.ts | 256 ++---------------- packages/subagent/subagent/src/invariant.ts | 3 +- packages/subagent/subagent/src/lifecycle.ts | 244 +++++++++++++++++ packages/subagent/subagent/src/types.ts | 42 ++- .../subagent/tests/continuation.spec.ts | 151 +++++------ .../subagent/subagent/tests/service.spec.ts | 2 +- .../tool-subagent-control/package.json | 2 - .../tool-subagent-control/src/index.ts | 2 +- pnpm-lock.yaml | 6 - scripts/gen-cordis-catalog.ts | 6 +- scripts/type-equiv.manifest.json | 10 - 25 files changed, 524 insertions(+), 671 deletions(-) create mode 100644 packages/subagent/subagent/src/lifecycle.ts diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 7e2a36b502..70a926d303 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.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-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: a56da8ad389964dcc873a722a66e335062811f37 -2026-07-28-continuable-subagent-conversations.zh.md: 71089cd71ae6fda7712ffcc614852a483e13e3ba +2026-07-28-continuable-subagent-conversations.md: df2aaa71dde4980bf2dd533c11254d0db8fe61b3 +2026-07-28-continuable-subagent-conversations.zh.md: 4437e73a3fa2f4d2043d2cfffe71259754fddeef diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index a56da8ad38..df2aaa71dd 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -10,11 +10,11 @@ This record replaces the Task-backed continuation manager from [Continuable back The previous continuation manager made one Task, one provider execution, and one result boundary the same object lifetime. Task settlement disposed the child Agent, Task completion injected the completion notice, and later input reconstructed another Agent. That coupled a generic background-work abstraction to conversation delivery even though a continuable subagent already has a Session and an Agent inbox. -Giving queued parent requests to the continuation manager and user messages to the Agent would create two FIFOs with no single ordering authority. Giving both to Tasks instead duplicated the Agent loop's admission, cancellation, and quiescence machinery. `Agent.whenIdle()` cannot recover a per-request Task result because one running interval may drain multiple queued turns, and broad `Agent.cancel()` cannot remove one queued request exactly. +Giving queued continuation requests to the manager while the Agent retained its own inbox would create two FIFOs with no single ordering authority. Giving all messages to Tasks instead duplicated the Agent loop's admission, cancellation, and quiescence machinery. `Agent.whenIdle()` cannot recover a per-request Task result because one running interval may drain multiple queued turns, and broad `Agent.cancel()` cannot remove one queued request exactly. The runtime lifetime is also wider than one turn. A subagent can finish its own turn while a child it created is still running. Disposing the parent runtime at that point removes the Agent that still owns descendant teardown. Keeping every historical subagent resident instead would make memory use unbounded. -Users and parent Agents also need to send later work to the same live child without changing its current turn. Queueing every continuation message as a follow-up preserves one ordering rule for both senders. +Parent Agents need to send later work to the same live child without changing its current turn. Queueing every continuation message as a follow-up preserves one ordering rule. ## Decision @@ -44,7 +44,7 @@ Cold resume does not dispatch through a subagent provider. The continuation mana `SubagentProvider.start()` and `SubagentRun` remain exclusively on the unchanged one-shot path. A continuable Activation directly owns its `AgentHandle` and never creates, wraps, or retains a `SubagentRun`; `SubagentRun.steer?()` is therefore absent. -`ctx.subagents.followup(authority, childId, content, { source, signal })` remains the sole continuation-message operation. `authority` is either `{ kind: 'parent', agent }` or `{ kind: 'user' }`; the parent variant is admitted only from an exact live Agent tool context, while only a trusted host adapter can supply user authority. `source` remains durable provenance and grants no authority. The model-facing `send_message` tool keeps only its stable `subagent_id` and `message` fields and always submits a follow-up turn. Both start and follow-up return the accepted `MessageId`, and neither reports how the manager materialized the Activation. +`ctx.subagents.followup(parent, childId, content, { source, signal })` remains the sole continuation-message operation. The exact live parent Agent authorizes delivery; `source` remains durable provenance and grants no authority. The model-facing `send_message` tool keeps only its stable `subagent_id` and `message` fields and always submits a follow-up turn. Both start and follow-up return the accepted `MessageId`, and neither reports how the manager materialized the Activation. For start and follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance. After the operation returns its `MessageId`, the manager owns the Activation independently; later caller cancellation does not cancel the accepted turn or dispose the child. @@ -52,13 +52,13 @@ For start and follow-up, the caller signal owns lookup, materialization, and adm The Session owns the stable child identity, transcript, direct-parent lineage, delegation depth, and versioned continuation descriptor. `SessionHeader.parentSession` is durable provenance and an authorization input; it is not a live routing capability and does not imply that the historical parent is resident. -An idle historical Session has no `AgentHandle`. The first authorized `next-turn` delivery resumes an Activation from the persisted Session and submits the message to its inbox. A user-authorized cold resume does not load the historical parent Agent. A parent-originated resume uses the exact live parent Agent for authorization and, when that parent has an Activation, ownership; it never uses the parent for reconstruction. +An idle historical Session has no `AgentHandle`. The first authorized `next-turn` delivery resumes an Activation from the persisted Session and submits the message to its inbox. Cold resume uses the exact live parent Agent for authorization and, when that parent has an Activation, ownership; it never uses the parent for reconstruction. The Activation directly owns the published `AgentHandle` until it settles, while the manager's private activation-owner scope is its structural Cordis owner. The continuable path creates no intermediate result-bearing execution wrapper, including `SubagentRun`; one-shot delegation remains unchanged and outside this lifecycle. Remote providers are out of scope here and require a separate Activation ownership contract when introduced. Historical Sessions consume no runtime memory after their Activation is disposed. ### Activation lifecycle -The public lifecycle has three states and no `queued` state: +The internal residency lifecycle has three conditions and no separate `queued` state: ```text running @@ -89,11 +89,11 @@ The Agent inbox is the only queue. Every continuation message uses `Agent.follow Routing depends only on Activation residency: -| Activation state | Sender | `followup` | -|---|---|---| -| `running` | parent or user | enqueue in the same Activation | -| `waiting` | parent or user | wake the same Activation | -| no Activation | parent or user | cold-resume a new Activation | +| Activation state | `followup` | +|---|---| +| `running` | enqueue in the same Activation | +| `waiting` | wake the same Activation | +| no Activation | cold-resume a new Activation | The continuation layer defines no separate delivery-route result. Successful `ctx.subagents.followup()` and `send_message` delivery returns the accepted `MessageId`, while delivery failure throws. Existing `agent/inbox/enqueue`, `agent/inbox/dequeue`, and `agent/inbox/discard` events remain the message-lifecycle observations; adapters may render a generic acceptance but do not expose `started`, `queued`, `resumed`, or another subagent-specific route vocabulary. @@ -103,13 +103,11 @@ Every Activation owns its `AgentHandle` and an `ownedChildren: Set`. When the authenticated parent is itself a continuation-managed Activation, starting a child or submitting parent-originated work adds the child Session id to that parent's `ownedChildren` before the child can run or the message can enter its inbox. That parent cannot settle or dispose while this set is non-empty. A top-level or other non-continuation Agent has no Activation and does not join this waiting graph. -Child release occurs only after the child Agent is quiescent, every child of that child is disposed, the final durability checkpoint settles, and the child's `AgentHandle` completes disposal. The manager calls `ctx.sessions.flush(child.session)`: `true` confirms durability, while `false` or rejection is normalized to `DURABILITY_FAILED`. A failed checkpoint is reported but does not prevent handle disposal or ownership release, because retaining a failed child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`; a user-resumed child with no live owner has nothing to release. Manager teardown uses the same child-first order. - -A user cold-resume creates an Activation without adding it to the historical parent's `ownedChildren`. If the direct parent later submits work to that live Activation and is itself continuation-managed, admission establishes ownership before enqueueing the message; a non-continuation parent remains outside the waiting graph. +Child release occurs only after the child Agent is quiescent, every child of that child is disposed, the final durability checkpoint settles, and the child's `AgentHandle` completes disposal. The manager calls `ctx.sessions.flush(child.session)`: `true` confirms durability, while `false` or rejection is normalized to `DURABILITY_FAILED`. A failed checkpoint is reported but does not prevent handle disposal or ownership release, because retaining a failed child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`. Manager teardown uses the same child-first order. Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. -Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain and includes user-resumed Activations without live owners. +Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain. The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. The manager snapshots the live roots after closing admission, stops its outward lifecycle notifications before cancellation, and retains its internal ownership bookkeeping until every handle settles. Each Activation has one memoized disposal promise so host shutdown, manager unload, child release, and normal settlement can converge without double release. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining handles, and the aggregate drain reports failure after all branches settle. Durable child Sessions survive this process-local teardown. @@ -121,21 +119,21 @@ A later proposal may add an ordinary model-facing `report(output)` tool that can ### Deferred steering -This version exposes no subagent steering operation. Parent and user continuation messages always open later FIFO turns, so the continuation layer stores no current-turn controller and adds no controller-aware Agent admission seam. +This version exposes no subagent steering operation. Parent continuation messages always open later FIFO turns, so the continuation layer stores no current-turn controller and adds no controller-aware Agent admission seam. -A later host UI may expose separate **Steer** and **Follow up** actions. User steering would be strict and live-only: it may call the existing Agent steering path only while the Activation accepts a next step, must reject otherwise, and must never fall back to queueing or cold resume. Exposing parent steering to a model-facing tool remains a separate design because distinct tool names express intent but do not establish whether the parent may modify a user-controlled turn. +A later host UI may expose separate **Steer** and **Follow up** actions. Host steering would be strict and live-only: it may call the existing Agent steering path only while the Activation accepts a next step, must reject otherwise, and must never fall back to queueing or cold resume. Exposing parent steering to a model-facing tool remains a separate design. ### Authority and provenance -Authority is supplied by a trusted host interaction or an exact live Agent tool context. `MessageSource` and `senderSessionId` are durable provenance after admission, not caller-controlled authority. +Authority is supplied by an exact live Agent tool context. `MessageSource` and `senderSessionId` are durable provenance after admission, not caller-controlled authority. -This version authorizes the host user and the durable child's direct parent. Parent authorization checks `SessionHeader.parentSession` against the authenticated parent Agent before registering the child in that parent's `ownedChildren`. Other Agents, ancestors, teams, and workflows remain rejected until an explicit authority protocol exists. +This version authorizes only the durable child's direct parent. The manager checks `SessionHeader.parentSession` against the exact live parent Agent before registering the child in that parent's `ownedChildren`. Other Agents, ancestors, hosts, teams, and workflows remain rejected until a concrete consumer justifies another authority protocol. -User authority may cold-resume a child without its parent. Parent-originated delivery requires the parent to be live when admitted and keeps it live through the ownership relationship. +Parent-originated delivery requires the parent to be live when admitted and keeps it live through the ownership relationship. ### Durability, disposal, and recovery -Without Tasks there is no `task_output`, `task_kill`, Task status, per-message result promise, or public subagent cancellation operation. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, neither parent nor user can cancel the message, turn, or Activation through `ctx.subagents`; `Agent.cancel()` remains a lower-level Agent capability that this version does not expose through the subagent service. +Without Tasks there is no `task_output`, `task_kill`, Task status, per-message result promise, or public subagent cancellation operation. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, the parent cannot cancel the message, turn, or Activation through `ctx.subagents`; `Agent.cancel()` remains a lower-level Agent capability that this version does not expose through the subagent service. Host and manager teardown remains the lifecycle-wide stop path. It closes admission, disposes every live Activation forest child-first, and preserves the durable Sessions. @@ -149,7 +147,7 @@ Session and descriptor persistence survive restart. Activation state, Agent inbo This version covers continuable in-process children and leaves one-shot delegation unchanged. Remote providers require a separate Activation handle with equivalent authenticated control and child-first quiescence contracts before they can support the same behavior. -It adds no subagent steering operation, report tool, child-to-parent content delivery, automatic parent wakeup, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public subagent cancellation operation, new live-Activation or descendant limit, or runtime cache. Existing delegation-depth policy remains unchanged. +It adds no host-user continuation, subagent steering operation, report tool, child-to-parent content delivery, automatic parent wakeup, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public subagent cancellation operation, public residency query, new live-Activation or descendant limit, or runtime cache. Existing delegation-depth policy remains unchanged. ## Alternatives considered @@ -159,7 +157,7 @@ It adds no subagent steering operation, report tool, child-to-parent content del **Dispose the Agent while waiting.** Reconstructing a parent while its child still belongs to the previous process-local ownership graph would require a durable ownership and teardown protocol. Retaining the `AgentHandle` only for the unfinished graph preserves child-first teardown without keeping settled history resident. -**Let the provider create, resume, or deliver through an Agent handle.** Initial providers own only `prepareContinuable()` and its detached creation-spec distinction: whether a child begins fresh or with a parent prefix. The manager must call `ctx.agents.create()` through its private activation-owner scope so that scope is a structural owner of every handle. A persisted in-process Session already contains the initial prefix and generic reconstruction descriptor, while delivery belongs to the Agent inbox. Giving providers any later handle, `SubagentRun`, or message ownership would preserve a seam with no shipped behavior to own and would complicate user cold resume with an unnecessary live-parent input. +**Let the provider create, resume, or deliver through an Agent handle.** Initial providers own only `prepareContinuable()` and its detached creation-spec distinction: whether a child begins fresh or with a parent prefix. The manager must call `ctx.agents.create()` through its private activation-owner scope so that scope is a structural owner of every handle. A persisted in-process Session already contains the initial prefix and generic reconstruction descriptor, while delivery belongs to the Agent inbox. Giving providers any later handle, `SubagentRun`, or message ownership would preserve a seam with no shipped behavior to own. **Add report delivery now.** A repeatable model-facing tool is compatible with this lifecycle, but quiet versus waking delivery, recipient selection, acknowledgement, durability, and retry behavior are independent product choices. Deferring the tool keeps the first version focused on conversation admission and residency without constraining that later policy. @@ -167,9 +165,11 @@ It adds no subagent steering operation, report tool, child-to-parent content del **Retain the exact parent Agent in a separate link.** The parent Activation already owns its `AgentHandle`, and `ownedChildren` prevents that Activation from disposing while the child remains live. Resolving the parent by Session id is therefore sufficient and avoids a redundant runtime reference. -**Maintain a separate queue for parent messages.** A second FIFO creates ambiguous ordering against user messages already accepted by the Agent. A single Agent inbox gives both origins one observable order. +**Maintain a separate queue for continuation messages.** A second FIFO creates ambiguous ordering against messages already accepted by the Agent. A single Agent inbox gives every accepted turn one observable order. -**Expose subagent steering now.** User steering can be a strict live-only host action, but parent steering needs current-turn controller state to protect a user-controlled turn. Queueing every first-version continuation avoids that state and its admission race. A later UI can add a distinct user-only action without changing follow-up ordering. +**Expose subagent steering now.** Parent steering needs current-turn controller state and a separate admission policy from follow-up delivery. Queueing every first-version continuation avoids that state and its admission race. + +**Expose host-user follow-up without a host consumer.** A public authority-minting method and user branch would make cold resume possible without the historical parent, but no production host adapter calls that operation. The seam accepts only the exact live parent until a concrete authenticated host interaction can receive a private capability. **Return a subagent-specific delivery route.** Labels such as `started`, `queued`, and `resumed` duplicate Activation and inbox state without giving the caller an independent result. Reusing `MessageId` and the existing inbox events keeps delivery correlation on the Agent contract that owns it. @@ -185,9 +185,8 @@ The implementation pins these behaviors: - Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership. - Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through the initial subagent provider; `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. - A continuable Activation directly owns `AgentHandle` and never creates, wraps, or retains `SubagentRun`; `SubagentProvider.start()` and `SubagentRun` remain one-shot-only, without `SubagentRun.steer?()`. -- A user can cold-resume a persisted child without loading its historical parent. -- `followup()` accepts only trusted parent or user authority; durable message provenance cannot authorize delivery. -- Parent and user continuation messages always use `Agent.followup()` and share its inbox FIFO, including when one origin queues behind the other or the child already has an open turn. +- `followup()` accepts only the exact live direct parent; durable message provenance cannot authorize delivery. +- Continuation messages always use `Agent.followup()` and share its inbox FIFO, including when the child already has an open turn. - `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `MessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result. - This version exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host and manager teardown retains child-first global cleanup. - This version exposes no subagent steering operation or current-turn controller state. @@ -201,7 +200,7 @@ The implementation pins these behaviors: - No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. - Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance failure, caller-signal ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. - Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools. -- A keyless assembled-app snapshot covers parent delegation, mixed parent/user follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal. +- A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal. ### Accepted costs @@ -213,6 +212,6 @@ The process-local inbox and ownership graph do not coordinate two harness proces Without report delivery, completing a child turn neither sends its content to nor wakes the historical parent. The output remains in the durable child Session until a caller inspects that transcript or submits another authorized turn. A later report tool may add quiet or waking delivery without changing the Activation lifecycle. -Queueing every continuation message means a parent cannot correct an in-progress child turn immediately; the correction runs as the next turn. A later user-only UI steering action may reduce that latency without introducing parent-versus-user controller policy here. +Queueing every continuation message means a parent cannot correct an in-progress child turn immediately; the correction runs as the next turn. A later UI steering action may reduce that latency without changing follow-up ordering. A failed final durability checkpoint allows the runtime ownership graph to drain but leaves the persisted child state missing or stale. The failure is observable as `DURABILITY_FAILED`; retry and repair require a separate recovery design. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 71089cd71a..4437e73a3f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -10,11 +10,11 @@ Status: implemented 以前的继续执行管理器让一个 Task、一次提供方执行和一个结果边界共享同一生命周期。Task 结算会 dispose(资源释放)child Agent,Task 完成会注入完成通知,后续输入则重建另一个 Agent。这曾使通用后台工作抽象与会话投递耦合,而可继续 subagent 已经具备会话和 Agent inbox。 -如果继续执行管理器为 parent 请求排队,而 Agent 接收用户消息,系统就会出现两个 FIFO,且没有唯一的顺序权威。而把两种消息都交给 Task,则重复了 agent loop(智能体循环)已有的准入、取消和完全停稳机制。`Agent.whenIdle()` 无法恢复单项请求的 Task 结果,因为一个运行区间可能清空多个排队轮次;宽泛的 `Agent.cancel()` 也不能精确移除一项排队请求。 +如果继续执行管理器为继续执行请求排队,而 Agent 保留自己的 inbox,系统就会出现两个 FIFO,且没有唯一的顺序权威。而把所有消息都交给 Task,则重复了 agent loop(智能体循环)已有的准入、取消和完全停稳机制。`Agent.whenIdle()` 无法恢复单项请求的 Task 结果,因为一个运行区间可能清空多个排队轮次;宽泛的 `Agent.cancel()` 也不能精确移除一项排队请求。 运行时生命周期也比单个轮次更长。subagent 可能已经结束自身轮次,但它创建的 child 仍在运行。此时 dispose parent 运行时,会移除仍负责后代拆卸的 Agent。反之,如果让所有历史 subagent 始终驻留,内存使用就会失去上界。 -用户和 parent Agent 还需要在不改变当前轮次的前提下,向同一个在线 child 发送后续工作。将每条继续执行消息作为 follow-up 排队,可以让两类发送方遵循同一项排序规则。 +parent Agent 还需要在不改变当前轮次的前提下,向同一个在线 child 发送后续工作。将每条继续执行消息作为 follow-up 排队,可以保留唯一的排序规则。 ## 决策 @@ -44,7 +44,7 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的 `SubagentProvider.start()` 和 `SubagentRun` 只保留在不变的 one-shot 路径上。可继续激活直接持有自身的 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;因此,`SubagentRun.steer?()` 不存在。 -`ctx.subagents.followup(authority, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。`authority` 可以是 `{ kind: 'parent', agent }` 或 `{ kind: 'user' }`;parent 变体仅能从确切的在线 Agent 工具上下文通过准入,只有可信宿主适配器才能提供用户权限。`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id` 和 `message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `MessageId`,两者都不报告管理器如何物化激活。 +`ctx.subagents.followup(parent, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。确切的在线 parent Agent 授权投递;`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id` 和 `message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `MessageId`,两者都不报告管理器如何物化激活。 对于 start 和 follow-up,调用方 signal 只在 inbox 接受消息前持有查找、物化和准入。操作返回 `MessageId` 后,管理器会独立持有该激活;调用方之后的取消不会取消已接受的轮次,也不会 dispose child。 @@ -52,13 +52,13 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的 会话持有稳定的 child 身份、transcript(文本记录)、直接 parent 谱系、委派深度和带版本的继续执行描述符。`SessionHeader.parentSession` 是持久化来源信息和鉴权输入;它不是在线路由能力,也不表示历史 parent 仍然驻留。 -空闲的历史会话没有 `AgentHandle`。第一条通过鉴权的 `next-turn` 投递会根据持久化会话恢复激活,并将消息提交到其 inbox。经用户授权的冷恢复不会加载历史 parent Agent。parent 发起的恢复使用经过身份认证的确切在线 parent Agent 执行鉴权;当该 parent 有激活时,还使用它建立所有权,但绝不使用 parent 执行重建。 +空闲的历史会话没有 `AgentHandle`。第一条通过鉴权的 `next-turn` 投递会根据持久化会话恢复激活,并将消息提交到其 inbox。冷恢复使用经过身份认证的确切在线 parent Agent 执行鉴权;当该 parent 有激活时,还使用它建立所有权,但绝不使用 parent 执行重建。 激活作为消费方会直接持有已发布的 `AgentHandle` 直至结算,而管理器的私有 activation-owner 作用域则是其 Cordis 结构化所有者。可继续 subagent 路径不创建任何中间的带结果执行包装层,包括 `SubagentRun`;一次性委派保持不变,且不属于该生命周期。远程提供方不在此处的范围内,引入时需要单独的激活所有权契约。激活 dispose 后,历史会话不消耗运行时内存。 ### 激活生命周期 -公开生命周期只有 3 个状态,没有 `queued` 状态: +内部驻留生命周期有三个条件,没有单独的 `queued` 状态: ```text running @@ -89,11 +89,11 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 路由只取决于激活的驻留状态: -| 激活状态 | 发送方 | `followup` | -|---|---|---| -| `running` | parent 或 user | 在同一激活中排队 | -| `waiting` | parent 或 user | 唤醒同一激活 | -| 无激活 | parent 或 user | 冷恢复新激活 | +| 激活状态 | `followup` | +|---|---| +| `running` | 在同一激活中排队 | +| `waiting` | 唤醒同一激活 | +| 无激活 | 冷恢复新激活 | 继续执行层不定义单独的投递路由结果。成功投递 `ctx.subagents.followup()` 或 `send_message` 时会返回已接受的 `MessageId`,投递失败则会抛出异常。现有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 和 `agent/inbox/discard` 事件仍用于观测消息生命周期;适配器可以呈现通用的接受确认,但不暴露 `started`、`queued`、`resumed` 或其他 subagent 专属路由词汇。 @@ -103,13 +103,11 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 当经过身份认证的 parent 自身是由继续执行管理器管理的激活时,启动 child 或提交由 parent 发起的工作,会在 child 可以运行或消息可以进入其 inbox 前,将 child 会话 id 加入该 parent 的 `ownedChildren`。该集合非空时,这个 parent 不能结算或 dispose。顶层 Agent 或其他非继续执行 Agent 没有激活,也不会加入该等待图。 -只有在 child Agent 完全停稳、该 child 持有的每个 child 都已 dispose、最终持久性检查点结算且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会调用 `ctx.sessions.flush(child.session)`:只有 `true` 确认持久性,`false` 或 rejection 则统一报告为 `DURABILITY_FAILED`。检查点失败会被报告,但不会阻止 handle dispose 或释放所有权,因为保留失败的 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id;由用户恢复且没有在线 owner 的 child 则没有需要释放的所有权记录。管理器拆卸使用相同的 child-first 顺序。 - -用户冷恢复会创建一次激活,但不会将其加入历史 parent 的 `ownedChildren`。如果直接 parent 随后向这个在线激活提交工作,且该 parent 自身由继续执行管理器管理,准入过程会在消息入队前建立所有权;非继续执行 parent 仍位于等待图之外。 +只有在 child Agent 完全停稳、该 child 持有的每个 child 都已 dispose、最终持久性检查点结算且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会调用 `ctx.sessions.flush(child.session)`:只有 `true` 确认持久性,`false` 或 rejection 则统一报告为 `DURABILITY_FAILED`。检查点失败会被报告,但不会阻止 handle dispose 或释放所有权,因为保留失败的 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id。管理器拆卸使用相同的 child-first 顺序。 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 -顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain,并涵盖由用户恢复且没有在线 owner 的激活。 +顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。 activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。管理器在关闭准入后对在线根节点创建快照,在取消前停止自身的对外生命周期通知,并保留内部所有权簿记,直至每个 handle 都结算。每次激活有一个记忆化的 dispose promise,使宿主关闭、管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余 handle,聚合 drain 则在所有分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。 @@ -121,21 +119,21 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect ### 延后的 steering(中途引导) -本版本不暴露 subagent steering 操作。parent 和用户的继续执行消息始终开启后续 FIFO 轮次,因此继续执行层不存储当前轮次控制方,也不新增能够感知控制方的 Agent 准入 seam。 +本版本不暴露 subagent steering 操作。parent 的继续执行消息始终开启后续 FIFO 轮次,因此继续执行层不存储当前轮次控制方,也不新增能够感知控制方的 Agent 准入 seam。 -后续宿主 UI 可以分别暴露 **Steer** 和 **Follow up** 操作。用户 steering 必须严格且仅限在线使用:只有当激活接受下一步骤时,它才能调用现有的 Agent steering 路径;其他情况必须拒绝,而且绝不能转为排队或冷恢复。是否通过面向模型的工具暴露 parent steering 仍需单独设计,因为不同的工具名称可以表达意图,却不能确定 parent 是否可以修改由用户控制的轮次。 +后续宿主 UI 可以分别暴露 **Steer** 和 **Follow up** 操作。宿主 steering 必须严格且仅限在线使用:只有当激活接受下一步骤时,它才能调用现有的 Agent steering 路径;其他情况必须拒绝,而且绝不能转为排队或冷恢复。是否通过面向模型的工具暴露 parent steering 仍需单独设计。 ### 权限与来源 -权限来自可信宿主交互或确切的在线 Agent 工具上下文。`MessageSource` 和 `senderSessionId` 是准入后的持久化来源信息,不是由调用方控制的权限。 +权限来自确切的在线 Agent 工具上下文。`MessageSource` 和 `senderSessionId` 是准入后的持久化来源信息,不是由调用方控制的权限。 -本版本授权宿主用户和持久化 child 的直接 parent。系统会根据经过身份认证的 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、团队和工作流仍被拒绝,直至系统具备显式权限协议。 +本版本只授权持久化 child 的直接 parent。管理器会根据确切的在线 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、宿主、团队和工作流仍被拒绝,直至有具体消费方证明另一种权限协议合理。 -用户权限可以在 parent 不在线时冷恢复 child。由 parent 发起的投递要求 parent 在准入时在线,并通过所有权关系使其继续在线。 +由 parent 发起的投递要求 parent 在准入时在线,并通过所有权关系使其继续在线。 ### 持久性、dispose 与恢复 -没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 和用户都不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本版本不通过 subagent 服务暴露它。 +没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本版本不通过 subagent 服务暴露它。 宿主和管理器拆卸仍是覆盖整个生命周期的停止路径。它会关闭准入,按 child-first 顺序 dispose 每个在线激活森林,并保留持久化会话。 @@ -149,7 +147,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 本版本覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳契约,才能支持同样的行为。 -它不新增 subagent steering 操作、报告工具、从 child 到 parent 的内容投递、自动唤醒 parent、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。 +它不新增 host-user 继续执行、subagent steering 操作、报告工具、从 child 到 parent 的内容投递、自动唤醒 parent、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、公开驻留查询、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。 ## 曾考虑的替代方案 @@ -159,7 +157,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect **等待期间 dispose Agent。** child 仍属于上一个进程内所有权图时重建 parent,需要持久化所有权与拆卸协议。只为尚未完成的所有权图保留 `AgentHandle`,可以在不让已结算历史驻留的前提下,保留 child-first 拆卸。 -**让提供方通过 Agent handle 创建、恢复 child 或投递消息。** 初始提供方只持有 `prepareContinuable()` 及其分离式创建规格这一项差异:child 是全新启动,还是带有 parent 前缀。管理器必须通过私有 activation-owner 作用域自行调用 `ctx.agents.create()`,使该作用域成为每个 handle 的结构化所有者。持久化的进程内会话已经包含初始前缀及通用重建描述符,消息投递则属于 Agent inbox。让提供方持有任何后续 handle、`SubagentRun` 或消息所有权,会保留一条没有已发布行为可承载的 seam,还会因不必要的在线 parent 输入使用户冷恢复更加复杂。 +**让提供方通过 Agent handle 创建、恢复 child 或投递消息。** 初始提供方只持有 `prepareContinuable()` 及其分离式创建规格这一项差异:child 是全新启动,还是带有 parent 前缀。管理器必须通过私有 activation-owner 作用域自行调用 `ctx.agents.create()`,使该作用域成为每个 handle 的结构化所有者。持久化的进程内会话已经包含初始前缀及通用重建描述符,消息投递则属于 Agent inbox。让提供方持有任何后续 handle、`SubagentRun` 或消息所有权,会保留一条没有已发布行为可承载的 seam。 **现在就增加报告投递。** 可重复调用的面向模型工具与该生命周期兼容,但静默投递还是唤醒投递、接收方选择、确认、持久性和重试行为都是独立的产品决策。延后该工具,可以让首个版本专注于会话准入与驻留,又不限制后续策略。 @@ -167,9 +165,11 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect **在单独的 link 中保留确切的 parent Agent。** parent 激活已经持有自身 `AgentHandle`,而且 `ownedChildren` 会在 child 仍然在线时阻止该激活 dispose。因此,通过会话 id 解析 parent 已经足够,也可以避免冗余的运行时引用。 -**为 parent 消息维护单独队列。** 第二个 FIFO 会让它和 Agent 已接受的用户消息之间顺序不明确。单个 Agent inbox 为两种来源提供唯一且可观察的顺序。 +**为继续执行消息维护单独队列。** 第二个 FIFO 会让它和 Agent 已接受消息之间顺序不明确。单个 Agent inbox 为每个已接受轮次提供唯一且可观察的顺序。 -**现在就暴露 subagent steering。** 用户 steering 可以是严格且仅限在线使用的宿主操作,但 parent steering 需要当前轮次控制方状态,以保护由用户控制的轮次。首个版本将每条继续执行消息都排队,可以避免引入该状态及其准入竞争。后续 UI 可以新增一项仅限用户的独立操作,而不改变 follow-up 排序。 +**现在就暴露 subagent steering。** parent steering 需要当前轮次控制方状态,以及不同于 follow-up 投递的单独准入策略。首个版本将每条继续执行消息都排队,可以避免引入该状态及其准入竞争。 + +**在没有 host 消费方的情况下暴露 host-user follow-up。** 公开的权限铸造方法和用户分支可以在没有历史 parent 的情况下实现冷恢复,但没有生产 host 适配器调用该操作。在具体的经认证宿主交互能够收到私有能力之前,该 seam 只接受确切的在线 parent。 **返回 subagent 专属的投递路由。** `started`、`queued` 和 `resumed` 等标签重复了激活与 inbox 状态,却没有给调用方提供独立结果。复用 `MessageId` 和现有 inbox 事件,可以让投递关联继续由其所属的 Agent 契约承载。 @@ -185,9 +185,8 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系。 - 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过初始 subagent 提供方分发;`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。 - 可继续激活直接持有 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;`SubagentProvider.start()` 和 `SubagentRun` 只用于 one-shot,且没有 `SubagentRun.steer?()`。 -- 用户可以在不加载历史 parent 的前提下冷恢复持久化 child。 -- `followup()` 只接受可信 parent 或用户权限;持久化消息来源信息不能授权投递。 -- Parent 和用户的继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括一种来源排在另一种来源之后,以及 child 已有开放轮次的情况。 +- `followup()` 只接受确切的在线直接 parent;持久化消息来源信息不能授权投递。 +- 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括 child 已有开放轮次的情况。 - `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 - 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 - 本版本不暴露 subagent steering 操作或当前轮次控制方状态。 @@ -201,7 +200,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 - 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前失败路径的完整回滚、接受前后两个阶段的调用方 signal 所有权,以及已接受但未写入日志的消息不会自动回放。 - 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 -- 一项无密钥整套应用快照覆盖 parent 委派、parent 与用户混合的 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 +- 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 ### 已接受的代价 @@ -213,6 +212,6 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 没有报告投递时,完成 child 轮次既不会把内容发送给历史 parent,也不会唤醒它。输出会保留在持久化 child 会话中,直至调用方检查该 transcript 或提交另一个经过授权的轮次。后续报告工具可以增加静默投递或唤醒投递,而无需改变激活生命周期。 -将每条继续执行消息排队,意味着 parent 无法立即纠正正在进行的 child 轮次;纠正操作会在下一个轮次执行。后续仅限用户的 UI steering 操作可以缩短该延迟,而无需在此引入 parent 与用户之间的控制方策略。 +将每条继续执行消息排队,意味着 parent 无法立即纠正正在进行的 child 轮次;纠正操作会在下一个轮次执行。后续 UI steering 操作可以缩短该延迟,而不改变 follow-up 排序。 最终持久性检查点失败时,运行时所有权图仍可完成 drain,但持久化 child 状态会缺失或陈旧。该失败会以 `DURABILITY_FAILED` 的形式被观测到;重试与修复需要单独的恢复设计。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 19302ab38e..28eea8cb5d 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -794,7 +794,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:143`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -811,7 +811,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:117`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:110`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -826,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:116`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -848,7 +848,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:127`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d8c55b94d1..fd51125a06 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1967,35 +1967,18 @@ async startContinuable(spec: ContinuableStartSpec): Promise * Deliver one later message to a continuable child as its next FIFO turn. A * resident child's Agent inbox accepts it directly (waking a `waiting` * Activation), while an absent one is cold-resumed from its persisted - * Session. The Agent inbox is the only queue, so parent and user messages - * share one observable order. - * @param authority - trusted parent or user authority for this delivery. + * Session. The Agent inbox is the only queue, so every accepted message has + * one observable order. + * @param parent - the exact live direct parent authorizing this delivery. * @param childId - durable child session id. * @param content - user-role content to deliver. * @param options - durable provenance and caller cancellation, which stops the * operation only before inbox acceptance. * @returns the accepted message's inbox id. - * @throws when continuation services are unavailable, authority is rejected, - * or the message was not admitted. + * @throws when continuation services are unavailable, parent authority is + * rejected, or the message was not admitted. */ -async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise - -/** - * Host-user authority for continuable operations, which may continue any - * durable child without its parent. A composition passes this only to a - * trusted host adapter carrying real human interaction; a model-facing tool - * uses `{ kind: 'parent', agent }` from its own execution context instead. - * @returns the authority a host adapter supplies to {@link followup}. - */ -userAuthority(): SubagentAuthority - -/** - * Read one durable child's live residency state. - * @param childId - durable child session id. - * @returns its Activation state, or `undefined` when no Activation is live. - * @throws when continuation services are unavailable. - */ -activationState(childId: SessionId): ActivationState | undefined +async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise /** * Close continuable admission synchronously, then dispose every live @@ -2040,9 +2023,9 @@ list(): string[] async start(name: string, request: SubagentStartRequest): Promise ``` -Types: [ActivationState](../core-data-structures/subagent.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentAuthority](../core-data-structures/subagent.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:176`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:141`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 3a8cd50e70..6a66f6d498 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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/subagent.md -subagent.md: ceff3586bf6724bd6f47b71e9fb737361a2830f8 -subagent.zh.md: aa39ea382fe1e2a52b6ee794cfa71d8abc945da3 +subagent.md: 81d09903bec3bd4767e73720be4a8d58c7837eb4 +subagent.zh.md: 6fd6845b5ceedd81e02365680a534e6d0c726aaf diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index ceff3586bf..81d09903be 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -99,7 +99,7 @@ Providers receive exactly this request: one-shot delegation has no service-resol ## Continuable children and activations -A **continuable background subagent** is one durable child Session with at most one process-local **Activation** — a residency epoch for a reconstructed child Agent. An Activation is not a request, result, cancellation, or Task boundary: it may execute many FIFO turns and stays resident while descendants it created are still running. The continuation manager owns activation admission, authority, the live ownership graph, cold resume, and child-first disposal; the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper. +A **continuable background subagent** is one durable child Session with at most one process-local **Activation** — a residency epoch for a reconstructed child Agent. An Activation is not a request, result, cancellation, or Task boundary: it may execute many FIFO turns and stays resident while descendants it created are still running. The continuation manager owns activation admission, direct-parent authorization, the live ownership graph, cold resume, and child-first disposal; the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper. ```text persisted Session @@ -113,17 +113,17 @@ persisted Session `SubagentService.followup()` is the sole continuation-message operation, and routing depends only on Activation residency: -| Activation state | Sender | `followup` | -|---|---|---| -| `running` | parent or user | enqueue in the same Activation | -| `waiting` | parent or user | wake the same Activation | -| no Activation | parent or user | cold-resume a new Activation | +| Activation state | `followup` | +|---|---| +| `running` | enqueue in the same Activation | +| `waiting` | wake the same Activation | +| no Activation | cold-resume a new Activation | -`running` means the Agent has an active admission or turn, or waking inbox work; `waiting` means it is quiescent but still owns at least one child Activation that has not completed disposal; `settled` means quiescent with every owned child disposed, at which point the manager disposes the `AgentHandle` and removes the Activation. The manager derives these from Agent quiescence and the owned-child set rather than maintaining a second execution state machine, and `activationState()` reports the current value (`undefined` when no Activation is live). +`running` means the Agent has an active admission or turn, or waking inbox work; `waiting` means it is quiescent but still owns at least one child Activation that has not completed disposal; `settled` means quiescent with every owned child disposed, at which point the manager disposes the `AgentHandle` and removes the Activation. The manager derives these internal conditions from Agent quiescence and the owned-child set rather than maintaining a second execution state machine. -The Agent inbox is the only queue. Every continuation message becomes one `Agent.followup()` FIFO turn, so parent and user messages share one observable order and a follow-up cannot redirect a turn already underway. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/enqueue`, `agent/inbox/dequeue`, and `agent/inbox/discard` events remain the message-lifecycle observations, and the continuation layer defines no subagent-specific delivery route. +The Agent inbox is the only queue. Every continuation message becomes one `Agent.followup()` FIFO turn, so accepted messages have one observable order and a follow-up cannot redirect a turn already underway. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/enqueue`, `agent/inbox/dequeue`, and `agent/inbox/discard` events remain the message-lifecycle observations, and the continuation layer defines no subagent-specific delivery route. -Authority is supplied by a trusted host interaction or an exact live Agent tool context. The parent variant is admitted only when the authenticated Agent is the durable child's direct parent recorded in `SessionHeader.parentSession`. User authority carries an opaque grant that only `SubagentService.userAuthority()` mints, so a caller cannot claim it by writing the discriminant — a plugin holding `ctx.subagents`, including model-generated mount code, would otherwise bypass the direct-parent check for any known child id. `MessageSource` and `senderSessionId` are durable provenance after admission and grant no authority — the optional model-facing tool uses `CoordinatorMessageSource`, while a host adapter uses `{ kind: 'user' }`. User authority may cold-resume a child without loading its historical parent. +Follow-up authority comes from an exact live Agent tool context. The authenticated Agent must be the durable child's direct parent recorded in `SessionHeader.parentSession`. `MessageSource` and `senderSessionId` are durable provenance after admission and grant no authority; the optional model-facing tool uses `CoordinatorMessageSource`. For both operations the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child, and the seam exposes no public subagent cancellation or steering operation. @@ -140,25 +140,6 @@ interface CoordinatorMessageSource { } ``` -```ts type-equiv -/** - * Who authorizes one continuable-subagent operation. Authority comes from a - * trusted host interaction or an exact live Agent tool context; durable - * {@link MessageSource} provenance never authorizes delivery. - */ -type SubagentAuthority = - /** The exact live parent Agent whose tool context is making the call. */ - | { readonly kind: 'parent'; readonly agent: Agent } - /** - * A trusted host adapter acting for the human user. The `grant` must be the - * exact token {@link SubagentService.userAuthority} minted, so a discriminant - * alone cannot claim this authority — any plugin holding `ctx.subagents`, - * including model-generated mount code, could otherwise forge it and bypass - * the direct-parent check. - */ - | { readonly kind: 'user'; readonly grant: UserAuthorityGrant } -``` - ```ts type-equiv /** Options for following up with one continuable child. */ interface SubagentFollowupOptions { @@ -179,18 +160,6 @@ interface ContinuableStart { } ``` -```ts type-equiv -/** - * The public residency state of one continuable child, derived from Agent - * quiescence and the owned-child set rather than a second state machine: - * `running` — the Agent has an active admission or turn, or waking inbox work; - * `waiting` — the Agent is quiescent but still owns undisposed children; - * `settled` — quiescent with every owned child disposed, so the manager - * disposes the `AgentHandle` and removes the Activation. - */ -type ActivationState = 'running' | 'waiting' | 'settled' -``` - The provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its returned spec carries only detached provider-specific creation inputs — today the optional parent-history seed — and no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. Cold resume does not dispatch through a provider at all: the manager folds the generic descriptor, calls `ctx.agents.resume()` through the same activation-owner scope, and submits the waiting turn. ```ts type-equiv diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index aa39ea382f..6fd6845b5c 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -99,7 +99,7 @@ interface SubagentStartRequest { ## 可继续子 agent 与激活 -**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**——即被重建的子 Agent 的一段驻留纪元(residency epoch)。Activation 不是请求、结果、取消或 Task 边界:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、授权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。 +**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**——即被重建的子 Agent 的一段驻留纪元(residency epoch)。Activation 不是请求、结果、取消或 Task 边界:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、直接父级鉴权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。 ```text persisted Session @@ -113,17 +113,17 @@ persisted Session `SubagentService.followup()` 是唯一的继续执行消息操作,其路由仅取决于 Activation 的驻留状态: -| Activation 状态 | 发送方 | `followup` | -|---|---|---| -| `running` | parent 或 user | 在同一 Activation 中入队 | -| `waiting` | parent 或 user | 唤醒同一 Activation | -| 无 Activation | parent 或 user | 冷恢复一个新的 Activation | +| Activation 状态 | `followup` | +|---|---| +| `running` | 在同一 Activation 中入队 | +| `waiting` | 唤醒同一 Activation | +| 无 Activation | 冷恢复一个新的 Activation | -`running` 表示 Agent 拥有活跃的准入或轮次,或正在唤醒收件箱工作;`waiting` 表示它已停稳,但仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示已停稳且其拥有的每个子级都已 dispose,此时管理器会 dispose `AgentHandle` 并移除该 Activation。管理器根据 Agent 的完全停稳状态与其拥有的子级集合推导这些状态,而非维护第二套执行状态机;`activationState()` 报告当前值(无存活 Activation 时为 `undefined`)。 +`running` 表示 Agent 拥有活跃的准入或轮次,或正在唤醒收件箱工作;`waiting` 表示它已停稳,但仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示已停稳且其拥有的每个子级都已 dispose,此时管理器会 dispose `AgentHandle` 并移除该 Activation。管理器根据 Agent 的完全停稳状态与其拥有的子级集合推导这些内部条件,而非维护第二套执行状态机。 -Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 `Agent.followup()` FIFO 轮次,因此 parent 与 user 消息共享同一个可观测顺序,且后续消息无法改变已在进行中的轮次。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 与 `agent/inbox/discard` 事件仍是消息生命周期的观测点,继续执行层不定义任何 subagent 专属的投递路由。 +Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 `Agent.followup()` FIFO 轮次,因此已接受的消息共享同一个可观测顺序,且后续消息无法改变已在进行中的轮次。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 与 `agent/inbox/discard` 事件仍是消息生命周期的观测点,继续执行层不定义任何 subagent 专属的投递路由。 -授权由受信任的宿主交互或一个确切的实时 Agent 工具上下文提供。仅当已认证的 Agent 是持久化子 agent 在 `SessionHeader.parentSession` 中记录的直接父级时,才会准入 parent 变体;只有受信任的宿主适配器才能提供 user 授权。`MessageSource` 与 `senderSessionId` 在准入之后是持久的来源凭据,不授予任何权限——可选的面向模型工具使用 `CoordinatorMessageSource`,宿主适配器则使用 `{ kind: 'user' }`。user 授权可以在不加载子 agent 历史父级的情况下冷恢复它。 +后续操作的权限来自确切的在线 Agent 工具上下文。已认证的 Agent 必须是持久化子 agent 在 `SessionHeader.parentSession` 中记录的直接父级。`MessageSource` 与 `senderSessionId` 在准入之后是持久的来源凭据,不授予任何权限;可选的面向模型工具使用 `CoordinatorMessageSource`。 对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 subagent 取消或 steering(中途引导)操作。 @@ -140,25 +140,6 @@ interface CoordinatorMessageSource { } ``` -```ts type-equiv -/** - * Who authorizes one continuable-subagent operation. Authority comes from a - * trusted host interaction or an exact live Agent tool context; durable - * {@link MessageSource} provenance never authorizes delivery. - */ -type SubagentAuthority = - /** The exact live parent Agent whose tool context is making the call. */ - | { readonly kind: 'parent'; readonly agent: Agent } - /** - * A trusted host adapter acting for the human user. The `grant` must be the - * exact token {@link SubagentService.userAuthority} minted, so a discriminant - * alone cannot claim this authority — any plugin holding `ctx.subagents`, - * including model-generated mount code, could otherwise forge it and bypass - * the direct-parent check. - */ - | { readonly kind: 'user'; readonly grant: UserAuthorityGrant } -``` - ```ts type-equiv /** Options for following up with one continuable child. */ interface SubagentFollowupOptions { @@ -179,18 +160,6 @@ interface ContinuableStart { } ``` -```ts type-equiv -/** - * The public residency state of one continuable child, derived from Agent - * quiescence and the owned-child set rather than a second state machine: - * `running` — the Agent has an active admission or turn, or waking inbox work; - * `waiting` — the Agent is quiescent but still owns undisposed children; - * `settled` — quiescent with every owned child disposed, so the manager - * disposes the `AgentHandle` and removes the Activation. - */ -type ActivationState = 'running' | 'waiting' | 'settled' -``` - 提供方只参与准备初始创建 spec,`spawn` 与 `fork` 在此有所不同。其返回的 spec 只携带分离的、提供方专属的创建输入——目前是可选的父级历史种子——不含 Agent、`AgentHandle`、prompt 投递、结果、dispose 或 resume 操作。冷恢复根本不经由提供方分发:管理器折叠通用描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,并提交等待中的轮次。 ```ts type-equiv diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ce2e06088a..7cccf7ab63 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:117`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:123`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:110`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:116`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:127`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 210024259f..59e901b2fe 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -889,16 +889,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Establish one durable continuable child and deliver its initial prompt.\n * Resolves when the child\'s inbox accepts that prompt, without waiting for the\n * turn to start or for the message to reach the Session log; any earlier\n * failure rejects with no ids and rolls back the child entirely.\n * @param spec - provider, delegation request, and caller cancellation.\n * @returns the durable child id and the accepted prompt\'s message id.\n * @throws when continuation services are unavailable or materialization fails.\n */', }, { - signature: 'async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', - jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so parent and user messages\n * share one observable order.\n * @param authority - trusted parent or user authority for this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, authority is rejected,\n * or the message was not admitted.\n */', - }, - { - signature: 'userAuthority(): SubagentAuthority', - jsDoc: '/**\n * Host-user authority for continuable operations, which may continue any\n * durable child without its parent. A composition passes this only to a\n * trusted host adapter carrying real human interaction; a model-facing tool\n * uses `{ kind: \'parent\', agent }` from its own execution context instead.\n * @returns the authority a host adapter supplies to {@link followup}.\n */', - }, - { - signature: 'activationState(childId: SessionId): ActivationState | undefined', - jsDoc: '/**\n * Read one durable child\'s live residency state.\n * @param childId - durable child session id.\n * @returns its Activation state, or `undefined` when no Activation is live.\n * @throws when continuation services are unavailable.\n */', + signature: 'async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', + jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so every accepted message has\n * one observable order.\n * @param parent - the exact live direct parent authorizing this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, parent authority is\n * rejected, or the message was not admitted.\n */', }, { signature: 'async drainContinuable(): Promise', @@ -1579,10 +1571,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ - { - name: 'ActivationState', - declaration: 'export type ActivationState = \'running\' | \'waiting\' | \'settled\';', - }, { name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', @@ -2695,10 +2683,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'StreamChunk', declaration: '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};', }, - { - name: 'SubagentAuthority', - declaration: 'export type SubagentAuthority = {\n readonly kind: \'parent\';\n readonly agent: Agent;\n} | {\n readonly kind: \'user\';\n readonly grant: UserAuthorityGrant;\n};', - }, { name: 'SubagentCapabilities', declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', @@ -3039,10 +3023,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertTypeModel', declaration: 'export interface TypertTypeModel {\n readonly name: string;\n readonly declaration: string;\n}', }, - { - name: 'UserAuthorityGrant', - declaration: 'export type UserAuthorityGrant = {\n readonly __brand: \'SubagentUserAuthority\';\n};', - }, { name: 'UserInteractionProvider', declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise;\n}', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 9ddbf4f465..23bc8500fc 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 6a8016dc71d928c1770cc0769f99d2cb53c6b035 -README.zh.md: 53f553bd2747bebac0f2d42ac80ad8b6eb660c45 +README.md: 1b38d493efa1dbe86464ad376649ff37914067da +README.zh.md: ec907f466779fc5c8a503f003a50f4aaf41c8b49 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 6a8016dc71..1b38d493ef 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -30,14 +30,12 @@ Multiple providers may coexist under different names. This lets a deployment exp | `list()` | Return provider names in insertion order. | | `start(name, request)` | Validate an ordinary caller request, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. | | `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. | -| `followup(authority, childId, content, { source, signal })` | Deliver one later message to a continuable child as its next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `AgentMessageId`. A resident child's inbox accepts it directly (waking a `waiting` Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. | -| `userAuthority()` | Mint the host-user authority a trusted adapter passes to `followup()`. Composition hands this only to a host carrying real human interaction; a model-facing tool uses its own `{ kind: 'parent', agent }` instead. | -| `activationState(childId)` | Read one durable child's live residency state (`running`, `waiting`, or `settled`), or `undefined` when no Activation is live. | +| `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. | | `drainContinuable()` | Close continuable admission synchronously, then dispose every live Activation forest child-first. A host calls this before disposing top-level agents so no descendant outlives the runtime that owns its teardown. An aggregate error surfaces after every branch settles when any failed. | `SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child. -Authority for continuable operations comes from a trusted host interaction or an exact live Agent tool context: `SubagentAuthority` is `{ kind: 'parent', agent }` or `{ kind: 'user', grant }`, whose grant only `userAuthority()` mints so the discriminant alone cannot claim it. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority. Parent authority requires the exact live direct parent recorded in the child's durable header; user authority may continue any child, and may cold-resume it without loading its historical parent. +Follow-up authority comes from the exact live direct parent recorded in the child's durable header. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority. Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries. @@ -74,17 +72,17 @@ A local run publishes an ordinary child agent/session before `start()` fulfills, A continuable child has one durable Session and at most one process-local **Activation** — one residency epoch for a reconstructed child Agent, not a request, result, cancellation, or Task boundary. The Agent inbox is the only turn queue, so the continuation manager owns residency while the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper. -The public residency state has three values derived from Agent quiescence and the owned-child set, not a second state machine: `running` (an active admission, open turn, or waking inbox work), `waiting` (quiescent but still owning at least one undisposed child), and `settled` (quiescent with every owned child disposed, so the manager disposes the `AgentHandle` and removes the Activation). Every continuation message uses `Agent.followup()` and becomes one FIFO turn, so parent and user messages share one observable order with no steering of the current turn. Routing depends only on residency: `running` enqueues, `waiting` wakes the same Agent, and an absent Activation cold-resumes a new one. +The manager derives three internal residency conditions from Agent quiescence and the owned-child set rather than maintaining a second state machine: running (an active admission, open turn, or waking inbox work), waiting (quiescent but still owning at least one undisposed child), and settled (quiescent with every owned child disposed, so the manager disposes the `AgentHandle` and removes the Activation). Every continuation message uses `Agent.followup()` and becomes one FIFO turn with no steering of the current turn. Routing depends only on residency: running enqueues, waiting wakes the same Agent, and an absent Activation cold-resumes a new one. -The manager reserves the child identity, resolves the durable descriptor, calls `ctx.agents.create()` (or `ctx.agents.resume()` for cold resume) through a private activation-owner scope, installs the returned `AgentHandle` in the Activation, establishes any continuable-parent ownership, and then submits the prompt. Cold resume never dispatches through a provider — the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input — so a user can cold-resume a persisted child without loading its historical parent. +The manager reserves the child identity, resolves the durable descriptor, calls `ctx.agents.create()` (or `ctx.agents.resume()` for cold resume) through a private activation-owner scope, installs the returned `AgentHandle` in the Activation, establishes any continuable-parent ownership, and then submits the prompt. Cold resume never dispatches through a provider because the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input. A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` or rejection reports `DURABILITY_FAILED` and still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting`. ## Lifecycle events -The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each continuable Activation's residency epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that never becomes resident emits only the terminal edge, because it has no start edge to pair. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. +The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. -Run events are scoped to the delegating parent; a user-resumed continuable child has no delegating parent, so its lifecycle reaches unscoped listeners globally. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. +Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. Provider additions and removals also emit `subagent/provider-added` and `subagent/provider-removed`. Consumers such as the model-facing tool use those events because Cordis may load sibling plugins concurrently; configuration order does not prove registration order. @@ -104,6 +102,7 @@ No direct invalidation; the named consumers own any request-prefix changes. - **ACP children remain one-shot** — an ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children. - **No report delivery** — the MVP exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup; a completed child turn leaves its output in the durable child Session until a caller inspects that transcript or submits another authorized turn. -- **No subagent steering** — every continuation message opens a later FIFO turn, so a parent or user cannot redirect a turn already underway; the manager stores no current-turn controller state. +- **No host-user continuation** — `followup()` requires the exact live direct parent. A future host adapter needs a concrete authenticated interaction before the seam gains a separate user capability. +- **No subagent steering** — every continuation message opens a later FIFO turn, so a parent cannot redirect a turn already underway; the manager stores no current-turn controller state. - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol. - **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 53f553bd27..ec907f4667 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -30,14 +30,12 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `list()` | 按插入顺序返回提供方名称。 | | `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 | | `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | -| `followup(authority, childId, content, { source, signal })` | 将一条后续消息作为可继续子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `AgentMessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 `waiting` 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | -| `userAuthority()` | 铸造可信 host 适配器传给 `followup()` 的 host 用户权限。组合装配仅将其交给承载真实人类交互的 host;面向模型的工具改用自身执行上下文的 `{ kind: 'parent', agent }`。 | -| `activationState(childId)` | 读取某个持久化子 agent 的实时驻留状态(`running`、`waiting` 或 `settled`);无实时 Activation 时返回 `undefined`。 | +| `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | | `drainContinuable()` | 同步关闭可继续准入,然后以子先于父的顺序 dispose 每一个实时 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 | `SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。 -可继续操作的权限来自可信的 host 交互或准确的实时 Agent 工具上下文:`SubagentAuthority` 为 `{ kind: 'parent', agent }` 或 `{ kind: 'user', grant }`——其 grant 仅由 `userAuthority()` 铸造,因此仅凭判别式无法声明该权限。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。父级权限要求准确匹配子 agent 持久化 header 中记录的实时直接父级;用户权限可以继续任何子 agent,并且可以在不加载其历史父级的情况下将其冷恢复。 +后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。 同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 @@ -74,17 +72,17 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 可继续子 agent 拥有一个持久化 Session 和至多一个进程内 **Activation**——即被重建的子 agent 的一个驻留时段,而不是请求、结果、取消或 Task 边界。Agent inbox 是唯一的轮次队列,因此继续执行管理器负责驻留,而 Agent 循环负责所有轮次排序与执行。任何可继续路径都不会创建 Task 或中间的承载结果的包装器。 -公共驻留状态有三个取值,由 Agent 停稳状态和所拥有子集推导,而非第二个状态机:`running`(存在活跃准入、进行中的轮次或唤醒型 inbox 工作)、`waiting`(已停稳但仍拥有至少一个未 dispose 的子 agent)、`settled`(已停稳且所有拥有的子 agent 都已 dispose,因此管理器 dispose `AgentHandle` 并移除 Activation)。每条后续消息都使用 `Agent.followup()` 并成为一个 FIFO 轮次,因此父级和用户消息共享同一个可观察顺序,且不会对当前轮次进行 steering(中途引导)。路由只取决于驻留状态:`running` 入队、`waiting` 唤醒同一 Agent,无 Activation 时则冷恢复一个新的。 +管理器根据 Agent 停稳状态和所拥有子集推导三个内部驻留条件,而非维护第二个状态机:running(存在活跃准入、进行中的轮次或唤醒型 inbox 工作)、waiting(已停稳但仍拥有至少一个未 dispose 的子 agent)、settled(已停稳且所有拥有的子 agent 都已 dispose,因此管理器 dispose `AgentHandle` 并移除 Activation)。每条后续消息都使用 `Agent.followup()` 并成为一个 FIFO 轮次,且不会对当前轮次进行 steering(中途引导)。路由只取决于驻留状态:running 入队、waiting 唤醒同一 Agent,无 Activation 时则冷恢复一个新的。 -管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发——持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入——因此用户可以在不加载历史父级的情况下冷恢复持久化子 agent。 +管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发,因为持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入。 受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose(子先于父)。顶层及其他非继续执行的 Agent 没有 Activation,处于该等待图之外。最终结算只把 `ctx.sessions.flush(child.session) === true` 视为持久性确认;`false` 或拒绝会报告 `DURABILITY_FAILED`,但仍会 dispose 句柄并释放所有权,因为保留失败的子 agent 会使其祖先永久停留在 `waiting`。 ## 生命周期事件 -服务会为每次一次性运行以及每个可继续 Activation 的驻留时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;从未驻留过的可继续时段只发出终止边,因为它没有可配对的开始边。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 +服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 -运行事件受执行委派的父级作用域约束;用户恢复的可继续子 agent 没有执行委派的父级,因此其生命周期会全局到达无作用域的监听器。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 +运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 提供方新增和移除还会发出 `subagent/provider-added` 与 `subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。 @@ -104,6 +102,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 - **ACP 子 agent 仍为一次性**:ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。 - **无 report 投递**:MVP 不提供 `report` 工具、子到父的内容投递或自动唤醒父级;已完成的子 agent 轮次会把其输出留在持久化子 agent Session 中,直到调用方查看该 transcript 或提交另一个经授权的轮次。 -- **无 subagent steering**:每条后续消息都会开启后续 FIFO 轮次,因此父级或用户无法重定向已经在进行的轮次;管理器不保存任何当前轮次控制器状态。 +- **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。未来 host 适配器需要具体的经认证交互,才能让该 seam 获得单独的用户能力。 +- **无 subagent steering**:每条后续消息都会开启后续 FIFO 轮次,因此父级无法重定向已经在进行的轮次;管理器不保存任何当前轮次控制器状态。 - **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。 - **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent,但丢失的消息不会自动重放。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 632c546bd8..072e286b36 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -38,6 +38,7 @@ import { } from './child-agent.ts' import { seedDescriptorTurn } from './descriptor-seed.ts' import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts' +import type { ActivationObserver } from './lifecycle.ts' import { SubagentError } from './error.ts' /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -53,29 +54,6 @@ declare module '@deepseek-ai/dsh-llm' { } } -/** - * Who authorizes one continuable-subagent operation. Authority comes from a - * trusted host interaction or an exact live Agent tool context; durable - * {@link MessageSource} provenance never authorizes delivery. - */ -export type SubagentAuthority = - /** The exact live parent Agent whose tool context is making the call. */ - | { readonly kind: 'parent'; readonly agent: Agent } - /** - * A trusted host adapter acting for the human user. The `grant` must be the - * exact token {@link SubagentService.userAuthority} minted, so a discriminant - * alone cannot claim this authority — any plugin holding `ctx.subagents`, - * including model-generated mount code, could otherwise forge it and bypass - * the direct-parent check. - */ - | { readonly kind: 'user'; readonly grant: UserAuthorityGrant } - -/** - * Opaque proof that a caller obtained user authority from the service rather - * than constructing it. Only {@link SubagentService.userAuthority} mints one. - */ -export type UserAuthorityGrant = { readonly __brand: 'SubagentUserAuthority' } - /** What a caller asks for when starting a continuable background child. */ export interface ContinuableStartSpec { /** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */ @@ -106,44 +84,22 @@ export interface SubagentFollowupOptions { } /** - * The public residency state of one continuable child, derived from Agent - * quiescence and the owned-child set rather than a second state machine: + * The residency state of one continuable child, derived from Agent quiescence + * and the owned-child set rather than a second state machine: * `running` — the Agent has an active admission or turn, or waking inbox work; * `waiting` — the Agent is quiescent but still owns undisposed children; * `settled` — quiescent with every owned child disposed, so the manager * disposes the `AgentHandle` and removes the Activation. */ -export type ActivationState = 'running' | 'waiting' | 'settled' +type ActivationState = 'running' | 'waiting' | 'settled' /** - * Lifecycle observer for one Activation's residency epoch, so continuable - * children emit the same start/end pair as one-shot runs. + * Hooks the manager needs from the owning service. Declared here, by the + * dependent, so the manager states exactly what it requires instead of + * depending back on the whole {@link SubagentService}. Package-private: no + * consumer outside this package supplies a host. */ -export interface ActivationObserver { - /** - * Publish the start edge once the epoch is resident. - * @param child - the resident child agent, whose log suffix bounds this epoch. - */ - start(child: Agent): void - /** - * Snapshot the child-dependent terminal facts while the child is still - * registered, because handle disposal unregisters it and consumers resolve it - * to read the child's own log and scope. - * @param child - the quiescent child agent about to be released. - */ - capture(child: Agent): void - /** - * Publish the terminal edge exactly once, pairing this epoch's {@link start}, - * after the disposal outcome is known. Called only for a resident epoch: a - * failure before residency publishes no edge, because inventing one would - * report a lifecycle the child never had. - * @param failure - the teardown or durability failure, or `undefined` on success. - */ - settle(failure: unknown): void -} - -/** Hooks the manager needs from the owning service. */ -export interface ContinuationHost { +interface ContinuationHost { /** * Resolve one provider's continuable-creation contribution, or reject when * the provider is unknown or lacks the capability. @@ -156,10 +112,10 @@ export interface ContinuationHost { * Build the lifecycle observer for one Activation's residency epoch. * @param provider - the provider name recorded in the durable descriptor. * @param childId - the durable child session id. - * @param parent - the delegating parent for scoped dispatch, if any. + * @param parent - the exact live direct parent for scoped dispatch. * @returns the observer whose edges this epoch publishes. */ - observeActivation(provider: string, childId: SessionId, parent: Agent | undefined): ActivationObserver + observeActivation(provider: string, childId: SessionId, parent: Agent): ActivationObserver } /** @@ -257,8 +213,6 @@ export class SubagentContinuationManager { constructor( private readonly ctx: Context, private readonly host: ContinuationHost, - /** The single token that proves host-user authority for this manager. */ - private readonly userGrant: UserAuthorityGrant, ) { // Ordinary Cordis owner effects unwind in reverse registration order, which // cannot express the dynamic child graph. Register the private scope's @@ -274,17 +228,6 @@ export class SubagentContinuationManager { }.bind(this), 'subagents.continuations()') } - /** - * Read one durable child's live residency state. - * @param childId - the durable child session id. - * @returns its Activation state, or `undefined` when no Activation is live. - */ - activationState(childId: SessionId): ActivationState | undefined { - const activation = this.activations.get(childId) - if (activation === undefined) return undefined - return this.stateOf(activation) - } - /** * Start one continuable background child: reserve its durable identity, * resolve the provider's detached creation spec, create the child Agent @@ -343,7 +286,7 @@ export class SubagentContinuationManager { // window — a `subagent/start` listener can cancel synchronously — must // roll the child back instead of opening its first turn. await this.rollbackIfAborted(activation, spec.signal) - return this.submit(activation, request.prompt, { kind: 'user' }, { kind: 'parent', agent: parent }) + return this.submit(activation, request.prompt, { kind: 'user' }, parent) }) return { childId, messageId } } @@ -353,20 +296,20 @@ export class SubagentContinuationManager { * turn. Routing depends only on Activation residency: a `running` Activation * enqueues, a `waiting` one wakes the same Agent, and an absent one * cold-resumes a new Activation from the persisted Session. The Agent inbox - * is the only queue, so parent and user messages share one observable order. + * is the only queue, so every accepted message has one observable order. * * The caller signal owns lookup, materialization, and admission only until * inbox acceptance; afterwards the accepted turn cannot be cancelled through * this service. - * @param authority - trusted parent or user authority for this delivery. + * @param parent - the exact live direct parent authorizing this delivery. * @param childId - the durable child session id. * @param content - the user-role content to deliver. * @param options - durable provenance and caller cancellation. * @returns the accepted message's inbox id. - * @throws when authority, availability, or admission rejects the delivery. + * @throws when parent authority, availability, or admission rejects the delivery. */ async followup( - authority: SubagentAuthority, + parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, @@ -375,7 +318,7 @@ export class SubagentContinuationManager { while (true) { const live = await this.locks.run(childId, async () => { const activation = this.activations.get(childId) - if (activation === undefined) return this.coldResume(authority, childId, content, options) + if (activation === undefined) return this.coldResume(parent, childId, content, options) // A delivery that arrives after the disposal transaction began must not // reach a handle being torn down; wait for release, then cold-resume. /* v8 ignore next 3 -- the send-versus-dispose cutoff: reaching this arm needs a @@ -385,13 +328,13 @@ export class SubagentContinuationManager { if (activation.disposal !== undefined) { return activation.disposal.then(() => undefined, () => undefined) } - await this.authorizeLive(authority, activation) + await this.authorizeLive(parent, activation) // The caller signal owns admission until acceptance, so re-check it // here: the outer check cannot cover an abort that landed while // authorization yielded, and enqueueing afterwards would return a // message id for a delivery the caller already cancelled. options.signal.throwIfAborted() - return this.submit(activation, content, options.source, authority) + return this.submit(activation, content, options.source, parent) }) /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that * race reaches the retry below, which then cold-resumes a new Activation. */ @@ -472,7 +415,7 @@ export class SubagentContinuationManager { * descriptor is the whole reconstruction input. */ private async coldResume( - authority: SubagentAuthority, + parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, @@ -488,8 +431,8 @@ export class SubagentContinuationManager { options.signal.throwIfAborted() this.assertAdmitting() // Authorize the persisted header before folding: only the durable child's - // direct parent — or the host user — may continue it. - this.authorizeLineage(authority, childId, loaded.meta.parentSession) + // exact live direct parent may continue it. + this.authorizeLineage(parent, childId, loaded.meta.parentSession) // Fold only the child's own suffix: a fork seed replays the parent's log, // which may carry an ANCESTOR's descriptor when the parent is itself a // continuable child. @@ -504,7 +447,7 @@ export class SubagentContinuationManager { const activation = await this.materialize({ childId, provider: descriptor.provider, - parent: authority.kind === 'parent' ? authority.agent : undefined, + parent, agentOptions: { ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, @@ -513,7 +456,7 @@ export class SubagentContinuationManager { signal: options.signal, }) await this.rollbackIfAborted(activation, options.signal) - return this.submit(activation, content, options.source, authority) + return this.submit(activation, content, options.source, parent) } /** @@ -540,7 +483,7 @@ export class SubagentContinuationManager { private async materialize(inputs: { childId: SessionId provider: string - parent: Agent | undefined + parent: Agent /** Creation inputs; absent for a cold resume, which loads the persisted session. */ create?: { seed: readonly SessionEvent[]; meta: NonNullable } agentOptions: AgentOptions @@ -639,8 +582,7 @@ export class SubagentContinuationManager { * top-level or other non-continuation Agent has no Activation and stays * outside the waiting graph. */ - private acquireOwnership(parent: Agent | undefined, childId: SessionId): void { - if (parent === undefined) return + private acquireOwnership(parent: Agent, childId: SessionId): void { const parentActivation = this.activations.get(parent.id) if (parentActivation === undefined) return if (parentActivation.disposal !== undefined) { @@ -674,11 +616,11 @@ export class SubagentContinuationManager { activation: Activation, content: ContentBlock[], source: MessageSource, - authority: SubagentAuthority, + parent: Agent, ): MessageId { // Parent-originated delivery keeps the parent live through ownership, so // establish it before the message can enter the child's inbox. - if (authority.kind === 'parent') this.acquireOwnership(authority.agent, activation.childId) + this.acquireOwnership(parent, activation.childId) const message = createUserMessage({ content, source }) // `Agent.followup()` publishes `agent/inbox/enqueue` synchronously, so its // observers must see this Activation as busy before the call begins. @@ -699,39 +641,25 @@ export class SubagentContinuationManager { * Authorize delivery to a live Activation. A parent must be the exact live * direct parent recorded in the child's durable header. */ - private async authorizeLive(authority: SubagentAuthority, activation: Activation): Promise { + private async authorizeLive(parent: Agent, activation: Activation): Promise { await Promise.resolve() this.authorizeLineage( - authority, + parent, activation.childId, activation.handle.agent.session.header.parentSession, ) } /** - * Authorize one operation against the durable direct-parent lineage. User - * authority may continue any child without loading its parent; parent - * authority requires the exact live direct parent. Other agents, ancestors, - * teams, and workflows remain rejected until an explicit authority protocol - * exists. + * Authorize one operation against the durable direct-parent lineage. Other + * agents, ancestors, teams, workflows, and hosts remain rejected until an + * explicit authority protocol has a production consumer. */ private authorizeLineage( - authority: SubagentAuthority, + parent: Agent, childId: SessionId, parentSession: SessionId | undefined, ): void { - if (authority.kind === 'user') { - // Identity, not shape: a forged discriminant must not skip the - // direct-parent check for an arbitrary known child id. - if (authority.grant !== this.userGrant) { - throw new SubagentError( - `subagent "${childId}" delivery presented an invalid user-authority grant`, - 'UNAUTHORIZED', - ) - } - return - } - const parent = authority.agent if (this.ctx.agents.get(parent.id) !== parent) { throw new SubagentError( `subagent "${childId}" delivery requires the exact live parent agent`, diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 6cc8c0fbe7..5d66ff8da5 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -29,35 +29,31 @@ * @module @deepseek-ai/dsh-subagent */ -import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentCapabilities, SubagentProvider, - SubagentResult, SubagentRun, + SubagentRunEndInfo, + SubagentRunInfo, SubagentStartRequest, } from './types.ts' -import { SubagentRunId } from './types.ts' import { SubagentError } from './error.ts' import { assertSubagentMaxDepth } from './depth.ts' +import { createActivationObserver, createLifecycleEmitter, observeRun } from './lifecycle.ts' +import type { ActivationObserver, LifecycleEmitter } from './lifecycle.ts' import SubagentContinuationManager from './continuation.ts' import type { - ActivationObserver, - ActivationState, - UserAuthorityGrant, ContinuableStart, ContinuableStartSpec, - SubagentAuthority, SubagentFollowupOptions, } from './continuation.ts' @@ -93,15 +89,12 @@ export { } from './child-agent.ts' export type { ChildComposition } from './child-agent.ts' export type { - ActivationObserver, - ActivationState, - UserAuthorityGrant, ContinuableStart, ContinuableStartSpec, CoordinatorMessageSource, - SubagentAuthority, SubagentFollowupOptions, } from './continuation.ts' +export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts' declare module 'cordis' { interface Context { @@ -144,55 +137,25 @@ declare module 'cordis' { } } -/** Observe-only identifying detail for a ready subagent run. */ -export interface SubagentRunInfo { - /** Unique identity shared with the paired terminal event. */ - readonly runId: SubagentRunId - /** The provider that established the run. */ - readonly provider: string - /** The child agent's id. */ - readonly id: SessionId - /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ - readonly local: boolean -} - -/** Observe-only outcome detail for a settled subagent run. */ -export interface SubagentRunEndInfo { - /** Unique identity shared with the paired start event. */ - readonly runId: SubagentRunId - /** The provider that ran it. */ - readonly provider: string - /** The child agent's id. */ - readonly id: SessionId - /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ - readonly local: boolean - /** The terminal stop reason. */ - readonly stopReason: SubagentResult['stopReason'] - /** The child's final assistant output, absent on infrastructure rejection. */ - readonly lastAssistantMessage?: ContentBlock[] -} - /** Named provider registry with one-shot runs and continuable-child operations. */ export class SubagentService extends Service { private providers = new Map() private continuations: SubagentContinuationManager | undefined /** - * The process-local proof of host-user authority. Minted here so the value is - * unguessable and unforgeable: a caller must obtain it from - * {@link userAuthority}, which composition hands only to trusted host - * adapters. + * The contained lifecycle-edge publisher. Built here because scoped dispatch + * keys its carrier by this exact service instance, whose own context filter + * composes into the carrier. */ - private readonly userGrant = Object.freeze({ - __brand: 'SubagentUserAuthority', - }) as UserAuthorityGrant + private readonly emitLifecycle: LifecycleEmitter constructor(ctx: Context) { super(ctx, 'subagents') + this.emitLifecycle = createLifecycleEmitter(this.ctx, parent => scopeTarget(this, parent)) ctx.inject(['agents'], (childCtx: Context) => { const manager = new SubagentContinuationManager(childCtx, { prepareContinuable: (name, request) => this.prepareContinuable(name, request), observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent), - }, this.userGrant) + }) this.continuations = manager childCtx.effect(() => () => { /* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */ @@ -218,45 +181,24 @@ export class SubagentService extends Service { * Deliver one later message to a continuable child as its next FIFO turn. A * resident child's Agent inbox accepts it directly (waking a `waiting` * Activation), while an absent one is cold-resumed from its persisted - * Session. The Agent inbox is the only queue, so parent and user messages - * share one observable order. - * @param authority - trusted parent or user authority for this delivery. + * Session. The Agent inbox is the only queue, so every accepted message has + * one observable order. + * @param parent - the exact live direct parent authorizing this delivery. * @param childId - durable child session id. * @param content - user-role content to deliver. * @param options - durable provenance and caller cancellation, which stops the * operation only before inbox acceptance. * @returns the accepted message's inbox id. - * @throws when continuation services are unavailable, authority is rejected, - * or the message was not admitted. + * @throws when continuation services are unavailable, parent authority is + * rejected, or the message was not admitted. */ async followup( - authority: SubagentAuthority, + parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise { - return this.requireContinuations().followup(authority, childId, content, options) - } - - /** - * Host-user authority for continuable operations, which may continue any - * durable child without its parent. A composition passes this only to a - * trusted host adapter carrying real human interaction; a model-facing tool - * uses `{ kind: 'parent', agent }` from its own execution context instead. - * @returns the authority a host adapter supplies to {@link followup}. - */ - userAuthority(): SubagentAuthority { - return { kind: 'user', grant: this.userGrant } - } - - /** - * Read one durable child's live residency state. - * @param childId - durable child session id. - * @returns its Activation state, or `undefined` when no Activation is live. - * @throws when continuation services are unavailable. - */ - activationState(childId: SessionId): ActivationState | undefined { - return this.requireContinuations().activationState(childId) + return this.requireContinuations().followup(parent, childId, content, options) } /** @@ -329,7 +271,7 @@ export class SubagentService extends Service { this.assertCapabilities(provider, request) assertSubagentMaxDepth(request.maxDepth) if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema) - return this.observeRun(name, request.parent, await provider.start(request)) + return observeRun(this.emitLifecycle, name, request.parent, await provider.start(request)) } /** @@ -373,112 +315,15 @@ export class SubagentService extends Service { } /** - * Emit the start/end lifecycle pair for one continuable Activation's - * residency epoch. Observers see the same vocabulary as a one-shot run, so a - * child's start and settlement remain observable without exposing whether the - * manager materialized, woke, or cold-resumed it. Creation failure before - * residency reports only the terminal edge. + * Build the lifecycle observer for one continuable Activation's residency + * epoch, so the manager publishes its edges without owning event dispatch. */ private observeActivation( provider: string, childId: SessionId, - parent: Agent | undefined, + parent: Agent, ): ActivationObserver { - const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true } - // A cold resume replays earlier turns, so this epoch's telemetry must come - // from the suffix it actually produced — never the whole session, which - // would report a previous epoch's answer when this one opened no turn. - let boundary = 0 - // Assigned by `capture()`, which the disposal path always runs before - // `settle()`; a resident epoch therefore always has its facts by then. - let captured: { stopReason: SubagentResult['stopReason']; output?: ContentBlock[] } = { - stopReason: 'completed', - } - let settled = false - return { - start: (child: Agent): void => { - boundary = child.session.events.length - this.emitLifecycle('subagent/start', identity, parent) - }, - capture: (child: Agent): void => { - const own = child.session.events.slice(boundary) - const output = lastAssistantOutput(own) - captured = { - stopReason: epochStopReason(own), - ...output === undefined ? {} : { output }, - } - }, - settle: (failure: unknown): void => { - // Exactly one terminal edge per epoch: host shutdown, manager unload, - // child release, and normal settlement all converge on one disposal. - /* v8 ignore next -- the memoized disposal already collapses those callers into a - * single settle(); this guard keeps the edge single if that memoization ever changes. */ - if (settled) return - settled = true - const output = failure === undefined ? captured.output : undefined - this.emitLifecycle('subagent/end', { - ...identity, - stopReason: failure === undefined ? captured.stopReason : 'error', - ...output === undefined ? {} : { lastAssistantMessage: output }, - }, parent) - }, - } - } - - /** Emit the start/end lifecycle pair for one accepted run and return it. */ - private observeRun(name: string, parent: Agent, run: SubagentRun): SubagentRun { - const runId = SubagentRunId(randomUUID()) - const lifecycleIdentity = { - runId, - provider: name, - id: run.id, - local: run.localAgent !== undefined, - } - // Attach the terminal observer before dispatching start. Promise reactions - // still run after this synchronous start emission, preserving start → end. - void run.result.then( - (result) => { - this.emitLifecycle('subagent/end', { - ...lifecycleIdentity, - stopReason: result.stopReason, - lastAssistantMessage: result.output, - }, parent) - }, - () => { - this.emitLifecycle('subagent/end', { ...lifecycleIdentity, stopReason: 'error' }, parent) - }, - ) - this.emitLifecycle('subagent/start', lifecycleIdentity, parent) - return run - } - - /** - * Emit lifecycle events with per-listener synchronous and asynchronous - * exception containment. Payloads are borrowed immutable values. - */ - private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent | undefined): void - private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent | undefined): void - private emitLifecycle(name: 'subagent/provider-removed', info: string): void - private emitLifecycle( - name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed', - info: SubagentRunInfo | SubagentRunEndInfo | string, - parent?: Agent , - ): void { - // A user-resumed continuable child has no delegating parent to key the - // carrier by, so its lifecycle reaches unscoped listeners globally. - const dispatchArgs: unknown[] = parent === undefined - ? [name, info] - : [scopeTarget(this, parent), name, info] - for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) { - try { - const returned: unknown = callback(info) - void Promise.resolve(returned).catch((error: unknown) => { - this.ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`) - }) - } catch (error: unknown) { - this.ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`) - } - } + return createActivationObserver(this.emitLifecycle, provider, childId, parent) } /** Reject the first requested capability that the provider lacks. */ @@ -500,57 +345,4 @@ export class SubagentService extends Service { } } -/** - * Why this child's last ordinary turn ended, for the terminal lifecycle edge. - * The child's own `turn/end` is authoritative: teardown succeeding says nothing - * about whether the model errored, hit its token ceiling, or was cancelled, so - * deriving the reason from disposal would report failed work as completed. - * @param events - this epoch's own event suffix. - * @returns its terminal stop reason; `completed` when no ordinary turn closed. - */ -function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopReason'] { - const reason = findLastMessageTurnEnd(events)?.data.reason - // No ordinary turn closed, so nothing failed either. - if (reason === undefined) return 'completed' - switch (reason.kind) { - case 'max-tokens': - return 'max-tokens' - case 'aborted': - case 'interrupted': - case 'disposed': - return 'aborted' - case 'error': - return 'error' - case 'completed': - return 'completed' - /* v8 ignore next 3 -- `TurnEndReason` is merge-extensible, so this arm needs a - * backend that adds a variant; treating an unnameable reason as success would - * report failed work as completed. */ - default: - return 'error' - } -} - -/** - * The child's last assistant message content, for one Activation's terminal - * lifecycle edge. Absent when no assistant message reached the log. - * @param events - this epoch's own event suffix. - * @returns its final assistant content, or `undefined` when it produced none. - */ -function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined { - const message = events.findLast( - (event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message', - ) - return message?.data.message.content -} - -/** Render any listener-thrown value without letting coercion escape containment. */ -function renderThrown(value: unknown): string { - try { - return value instanceof Error ? `${value.name}: ${value.message}` : String(value) - } catch { - return '' - } -} - export default SubagentService diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index 3c350c13a1..dd224b68de 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -2,8 +2,7 @@ import type { Context } from 'cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import type { SubagentProvider } from './types.ts' -import type { SubagentRunEndInfo, SubagentRunInfo } from './index.ts' +import type { SubagentProvider, SubagentRunEndInfo, SubagentRunInfo } from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts new file mode 100644 index 0000000000..5d809a7edb --- /dev/null +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -0,0 +1,244 @@ +/** + * Lifecycle-edge publication for both subagent shapes: the contained emitter, + * the one-shot run observer, and the continuable Activation observer. + * + * The public payload contracts ({@link SubagentRunInfo}, + * {@link SubagentRunEndInfo}) live in `./types.ts` with the rest of the seam's + * consumer-facing types; this module owns only the implementation and the + * package-private {@link ActivationObserver} the continuation manager consumes. + * Keeping the internal control interface out of the published surface is + * deliberate: the observer's `start`/`capture`/`settle` ordering is a contract + * between this module and one in-package caller, not something a plugin may + * depend on. + * + * @module @deepseek-ai/dsh-subagent/lifecycle + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { SubagentRunId } from './types.ts' +import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts' + +/** + * Lifecycle observer for one Activation's residency epoch, so continuable + * children emit the same start/end pair as one-shot runs. Package-private: the + * continuation manager is the only consumer, and its call ordering is an + * in-package contract rather than a published extension seam. + */ +export interface ActivationObserver { + /** + * Publish the start edge once the epoch is resident. + * @param child - the resident child agent, whose log suffix bounds this epoch. + */ + start(child: Agent): void + /** + * Snapshot the child-dependent terminal facts while the child is still + * registered, because handle disposal unregisters it and consumers resolve it + * to read the child's own log and scope. + * @param child - the quiescent child agent about to be released. + */ + capture(child: Agent): void + /** + * Publish the terminal edge exactly once, pairing this epoch's {@link start}, + * after the disposal outcome is known. Called only for a resident epoch: a + * failure before residency publishes no edge, because inventing one would + * report a lifecycle the child never had. + * @param failure - the teardown or durability failure, or `undefined` on success. + */ + settle(failure: unknown): void +} + +/** + * Publish one lifecycle edge with per-listener exception containment. Run edges + * carry the delegating parent that keys scoped dispatch; provider removal has no + * parent carrier and reaches listeners unscoped. + * + * The service owns this closure because scoped dispatch keys its carrier by the + * exact service instance, whose own context filter composes into the carrier; + * a narrowed stand-in would silently change scope filtering. + */ +export type LifecycleEmitter = { + (name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void + (name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void + (name: 'subagent/provider-removed', info: string): void +} + +/** + * Build the contained lifecycle emitter this seam publishes every edge through. + * Every listener is independently contained: a synchronous throw or a rejected + * returned promise is logged without starving peer listeners, changing the run, + * or — for provider removal, which fires from a disposer — breaking teardown. + * @param ctx - the service's own context, owning dispatch and the logger. + * @param carrier - resolve the scoped dispatch carrier for one delegating parent. + * @returns the emitter both observers and the provider registry publish through. + */ +export function createLifecycleEmitter( + ctx: Context, + carrier: (parent: Agent) => object, +): LifecycleEmitter { + return ( + name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed', + info: SubagentRunInfo | SubagentRunEndInfo | string, + parent?: Agent, + ): void => { + const dispatchArgs: unknown[] = parent === undefined + ? [name, info] + : [carrier(parent), name, info] + for (const callback of ctx.events.dispatch('emit', dispatchArgs)) { + try { + const returned: unknown = callback(info) + void Promise.resolve(returned).catch((error: unknown) => { + ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`) + }) + } catch (error: unknown) { + ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`) + } + } + } +} + +/** + * Emit the start/end lifecycle pair for one accepted one-shot run. + * @param emit - the contained lifecycle emitter. + * @param provider - the provider that established the run. + * @param parent - the delegating parent keying scoped dispatch. + * @param run - the ready run whose settlement closes the pair. + * @returns the same run, unchanged. + */ +export function observeRun( + emit: LifecycleEmitter, + provider: string, + parent: Agent, + run: SubagentRun, +): SubagentRun { + const identity = { + runId: SubagentRunId(randomUUID()), + provider, + id: run.id, + local: run.localAgent !== undefined, + } + // Attach the terminal observer before dispatching start. Promise reactions + // still run after this synchronous start emission, preserving start → end. + void run.result.then( + (result) => { + emit('subagent/end', { + ...identity, + stopReason: result.stopReason, + lastAssistantMessage: result.output, + }, parent) + }, + () => { + emit('subagent/end', { ...identity, stopReason: 'error' }, parent) + }, + ) + emit('subagent/start', identity, parent) + return run +} + +/** + * Build the observer for one continuable Activation's residency epoch. Observers + * see the same vocabulary as a one-shot run, so a child's start and settlement + * remain observable without exposing whether the manager materialized, woke, or + * cold-resumed it. Creation failure before residency emits no lifecycle edge. + * @param emit - the contained lifecycle emitter. + * @param provider - the provider name recorded in the durable descriptor. + * @param childId - the durable child session id. + * @param parent - the exact live direct parent keying scoped dispatch. + * @returns the observer whose edges this epoch publishes. + */ +export function createActivationObserver( + emit: LifecycleEmitter, + provider: string, + childId: SessionId, + parent: Agent, +): ActivationObserver { + const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true } + // A cold resume replays earlier turns, so this epoch's telemetry must come + // from the suffix it actually produced — never the whole session, which + // would report a previous epoch's answer when this one opened no turn. + let boundary = 0 + // Assigned by `capture()`, which the disposal path always runs before + // `settle()`; a resident epoch therefore always has its facts by then. + let captured: { stopReason: SubagentResult['stopReason']; output?: ContentBlock[] } = { + stopReason: 'completed', + } + return { + start: (child: Agent): void => { + boundary = child.session.events.length + emit('subagent/start', identity, parent) + }, + capture: (child: Agent): void => { + const own = child.session.events.slice(boundary) + const output = lastAssistantOutput(own) + captured = { + stopReason: epochStopReason(own), + ...output === undefined ? {} : { output }, + } + }, + settle: (failure: unknown): void => { + const output = failure === undefined ? captured.output : undefined + emit('subagent/end', { + ...identity, + stopReason: failure === undefined ? captured.stopReason : 'error', + ...output === undefined ? {} : { lastAssistantMessage: output }, + }, parent) + }, + } +} + +/** + * Why this child's last ordinary turn ended, for the terminal lifecycle edge. + * The child's own `turn/end` is authoritative: teardown succeeding says nothing + * about whether the model errored, hit its token ceiling, or was cancelled, so + * deriving the reason from disposal would report failed work as completed. + * @param events - this epoch's own event suffix. + * @returns its terminal stop reason; `completed` when no ordinary turn closed. + */ +function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopReason'] { + const reason = findLastMessageTurnEnd(events)?.data.reason + // No ordinary turn closed, so nothing failed either. + if (reason === undefined) return 'completed' + switch (reason.kind) { + case 'max-tokens': + return 'max-tokens' + case 'aborted': + case 'interrupted': + case 'disposed': + return 'aborted' + case 'error': + return 'error' + case 'completed': + return 'completed' + /* v8 ignore next 3 -- `TurnEndReason` is merge-extensible, so this arm needs a + * backend that adds a variant; treating an unnameable reason as success would + * report failed work as completed. */ + default: + return 'error' + } +} + +/** + * The child's last assistant message content, for one Activation's terminal + * lifecycle edge. Absent when no assistant message reached the log. + * @param events - this epoch's own event suffix. + * @returns its final assistant content, or `undefined` when it produced none. + */ +function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined { + const message = events.findLast( + (event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message', + ) + return message?.data.message.content +} + +/** Render any listener-thrown value without letting coercion escape containment. */ +function renderThrown(value: unknown): string { + try { + return value instanceof Error ? `${value.name}: ${value.message}` : String(value) + } catch { + return '' + } +} diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 3ff7368b37..5c703c6ce5 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -1,5 +1,10 @@ /** - * Request, result, and capability contracts for {@link SubagentProvider}. + * The seam's consumer-facing contracts: request, result, and capability types + * for {@link SubagentProvider}, plus the `subagent/start` and `subagent/end` + * payloads that plugins and hosts observe. Internal control interfaces belong + * with their implementation — the lifecycle observer in `./lifecycle.ts`, the + * continuation host in `./continuation.ts` — so this module stays the published + * surface rather than a bag of everything type-shaped. * * @module @deepseek-ai/dsh-subagent/types */ @@ -22,6 +27,41 @@ export function SubagentRunId(id: string): SubagentRunId { return id as SubagentRunId } +/** + * Observe-only identifying detail for a ready subagent run, carried by + * `subagent/start`. One-shot runs and continuable Activation epochs share this + * payload, so an observer sees the same vocabulary for both. + */ +export interface SubagentRunInfo { + /** Unique identity shared with the paired terminal event. */ + readonly runId: SubagentRunId + /** The provider that established the run. */ + readonly provider: string + /** The child agent's id. */ + readonly id: SessionId + /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ + readonly local: boolean +} + +/** + * Observe-only outcome detail for a settled subagent run, carried by + * `subagent/end` and paired with one {@link SubagentRunInfo} by `runId`. + */ +export interface SubagentRunEndInfo { + /** Unique identity shared with the paired start event. */ + readonly runId: SubagentRunId + /** The provider that ran it. */ + readonly provider: string + /** The child agent's id. */ + readonly id: SessionId + /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ + readonly local: boolean + /** The terminal stop reason. */ + readonly stopReason: SubagentResult['stopReason'] + /** The child's final assistant output, absent on infrastructure rejection. */ + readonly lastAssistantMessage?: ContentBlock[] +} + /** * Which START-TIME features a provider supports. Checked by the service before delegating to * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 47373046f3..02dd7896ca 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -19,7 +19,7 @@ import SubagentService, { SubagentError, SUBAGENT_DESCRIPTOR_VERSION, } from '../src/index.ts' -import type { SubagentAuthority, SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts' +import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -109,12 +109,12 @@ function userTexts(events: readonly SessionEvent[]): string[] { function followup( ctx: Context, - authority: SubagentAuthority, + parent: Agent, childId: SessionId, content: ReturnType, signal: AbortSignal = testSignal, ) { - return ctx.subagents.followup(authority, childId, content, { + return ctx.subagents.followup(parent, childId, content, { source: { kind: 'user' }, signal, }) @@ -123,7 +123,6 @@ function followup( /** Wait until a child's Activation is gone, i.e. its handle finished disposal. */ async function waitNoActivation(ctx: Context, childId: SessionId): Promise { await vi.waitFor(() => { - expect(ctx.subagents.activationState(childId)).toBeUndefined() expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) } @@ -303,7 +302,8 @@ describe('SubagentService.startContinuable', () => { await fresh.plugin(AgentLoop, { agents: [] }) await fresh.plugin(SubagentService) await fresh.plugin(SubagentSpawn, { providerName: 'spawn' }) - await followup(fresh, fresh.subagents.userAuthority(), started.childId, message('resume routeless')) + const freshParent = fresh.agentLoop.create(SessionId('routeless-resume'), {}) + await followup(fresh, freshParent, started.childId, message('resume routeless')) const resumed = await vi.waitFor(() => { const found = fresh.agents.get(started.childId) @@ -353,7 +353,7 @@ describe('SubagentService.startContinuable', () => { expect(descriptor?.data).toMatchObject({ persona: 'You are scoped.' }) // Cold resume reconstructs the declared composition from that descriptor. - await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('resume it')) + await followup(ctx, parent, started.childId, message('resume it')) await waitNoActivation(ctx, started.childId) const resumed = await ctx.sessionPersistence.load(started.childId) expect(hasUserText(resumed.events, 'resume it')).toBe(true) @@ -372,19 +372,19 @@ describe('SubagentService.followup residency routing', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId) - expect(ctx.subagents.activationState(started.childId)).toBe('running') + expect(child?.status).toBe('running') - // Both origins queue behind the open turn, in call order. - const parentMessage = await followup(ctx, { kind: 'parent', agent: parent }, started.childId, message('from parent')) - const userMessage = await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('from user')) - expect(parentMessage).not.toBe(userMessage) + // Both messages queue behind the open turn, in call order. + const firstMessage = await followup(ctx, parent, started.childId, message('first follow-up')) + const secondMessage = await followup(ctx, parent, started.childId, message('second follow-up')) + expect(firstMessage).not.toBe(secondMessage) // Still the same Activation: no second child Agent was created. expect(ctx.agents.get(started.childId)).toBe(child) releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - expect(userTexts(loaded.events)).toEqual(['child task', 'from parent', 'from user']) + expect(userTexts(loaded.events)).toEqual(['child task', 'first follow-up', 'second follow-up']) }) it('cold-resumes a settled child into a new Activation', async () => { @@ -392,7 +392,7 @@ describe('SubagentService.followup residency routing', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) - const messageId = await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('continue please')) + const messageId = await followup(ctx, parent, started.childId, message('continue please')) expect(messageId).toBeTypeOf('string') await waitNoActivation(ctx, started.childId) @@ -421,12 +421,13 @@ describe('SubagentService.followup residency routing', () => { const grandchild = await ctx.subagents.startContinuable(startSpec(child)) await vi.waitFor(() => { expect(adapter.requests.length).toBeGreaterThanOrEqual(2) }) await vi.waitFor(() => { - expect(ctx.subagents.activationState(started.childId)).toBe('waiting') + expect(child.status).toBe('idle') + expect(ctx.agents.get(started.childId)).toBe(child) }, { timeout: 5_000 }) // Waiting retains the handle: the same Agent is still live. expect(ctx.agents.get(started.childId)).toBe(child) - await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('while waiting')) + await followup(ctx, parent, started.childId, message('while waiting')) // Woken back to running on the SAME Activation. expect(ctx.agents.get(started.childId)).toBe(child) @@ -437,58 +438,16 @@ describe('SubagentService.followup residency routing', () => { expect(userTexts(loaded.events)).toEqual(['child task', 'while waiting']) }) - it('rejects a forged user-authority grant', async () => { - const { ctx, parent } = await setup([textResponse('first')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) - await waitNoActivation(ctx, started.childId) - - // Any plugin holding `ctx.subagents` can write this shape, so shape alone - // must not skip the direct-parent check for an arbitrary known child id. - const forged = { kind: 'user', grant: { __brand: 'SubagentUserAuthority' } } as unknown as SubagentAuthority - await expect(followup(ctx, forged, started.childId, message('not really the user'))) - .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) - - // The service-minted grant is accepted. - await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('really the user'))) - .resolves.toBeTypeOf('string') - await waitNoActivation(ctx, started.childId) - }) - it('rejects a parent that is not the durable direct parent', async () => { const { ctx, parent } = await setup([textResponse('first')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) - await expect(followup(ctx, { kind: 'parent', agent: stranger }, started.childId, message('mine now'))) + await expect(followup(ctx, stranger, started.childId, message('mine now'))) .rejects.toThrow(/belongs to another parent session/) }) - it('lets user authority cold-resume a child without loading its historical parent', async () => { - const { ctx, parent, root } = await setup([textResponse('first')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) - await waitNoActivation(ctx, started.childId) - await ctx.sessionPersistence.load(started.childId) - - // A fresh runtime over the same store has no parent Agent at all. - const fresh = new Context() - await mountAgentLoopTestDependencies(fresh) - await fresh.plugin(JsonlSessionPersistence, { root: root! }) - await fresh.plugin(AgentLoop, { agents: [] }) - await fresh.plugin(SubagentService) - await fresh.plugin(SubagentSpawn, { providerName: 'spawn' }) - fresh.llm.registerAdapter(['mock'], new MockAdapter([textResponse('resumed cold')])) - expect(fresh.agents.get(SessionId('parent'))).toBeUndefined() - - await followup(fresh, fresh.subagents.userAuthority(), started.childId, message('user continues')) - await waitNoActivation(fresh, started.childId) - - const loaded = await fresh.sessionPersistence.load(started.childId) - expect(hasUserText(loaded.events, 'user continues')).toBe(true) - // The historical parent was never reconstructed. - expect(fresh.agents.get(SessionId('parent'))).toBeUndefined() - }) - it('reports an unresumable child whose persisted log has no supported descriptor', async () => { const { ctx, parent } = await setup([textResponse('one shot')]) // A ONE-SHOT child persists a log but never seeds a descriptor. @@ -502,13 +461,13 @@ describe('SubagentService.followup residency routing', () => { const oneShotId = run.id await run.dispose() - await expect(followup(ctx, ctx.subagents.userAuthority(), oneShotId, message('continue'))) + await expect(followup(ctx, parent, oneShotId, message('continue'))) .rejects.toThrow(/no supported continuation state/) }) it('reports an unknown child id as unavailable', async () => { - const { ctx } = await setup([]) - await expect(followup(ctx, ctx.subagents.userAuthority(), SessionId('missing'), message('hello'))) + const { ctx, parent } = await setup([]) + await expect(followup(ctx, parent, SessionId('missing'), message('hello'))) .rejects.toMatchObject({ code: 'NOT_RESUMABLE' }) }) @@ -524,7 +483,7 @@ describe('SubagentService.followup residency routing', () => { // exactly one side wins the cutoff. A delivery that loses awaits release and // cold-resumes rather than reaching a handle being torn down. const delivery = child.whenIdle().then(() => - followup(ctx, ctx.subagents.userAuthority(), started.childId, message('raced'))) + followup(ctx, parent, started.childId, message('raced'))) await expect(delivery).resolves.toBeTypeOf('string') await waitNoActivation(ctx, started.childId) @@ -550,7 +509,8 @@ describe('continuable child ownership', () => { const grandchild = await ctx.subagents.startContinuable(startSpec(child)) await vi.waitFor(() => { - expect(ctx.subagents.activationState(started.childId)).toBe('waiting') + expect(child.status).toBe('idle') + expect(ctx.agents.get(started.childId)).toBe(child) }, { timeout: 5_000 }) // Child-first: the parent handle is retained while the grandchild is live. expect(ctx.agents.get(started.childId)).toBe(child) @@ -565,8 +525,7 @@ describe('continuable child ownership', () => { const { ctx, parent } = await setup([textResponse('done')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) - // The top-level parent has no Activation of its own. - expect(ctx.subagents.activationState(parent.id)).toBeUndefined() + // The top-level parent remains independently registered after its child settles. expect(ctx.agents.get(parent.id)).toBe(parent) }) }) @@ -652,7 +611,7 @@ describe('continuable durability and teardown', () => { await expect(ctx.subagents.startContinuable(startSpec(parent))) .rejects.toMatchObject({ code: 'DRAINING' }) - await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('too late'))) + await expect(followup(ctx, parent, started.childId, message('too late'))) .rejects.toMatchObject({ code: 'DRAINING' }) }) @@ -663,7 +622,7 @@ describe('continuable durability and teardown', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) // Accepted into the inbox, but this queued turn never opens. - await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('never logged')) + await followup(ctx, parent, started.childId, message('never logged')) const drained = ctx.subagents.drainContinuable() hold.resolve(undefined) @@ -707,7 +666,7 @@ describe('continuable review regressions', () => { const controller = new AbortController() controller.abort('caller gave up') - await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('cancelled'), controller.signal)) + await expect(followup(ctx, parent, started.childId, message('cancelled'), controller.signal)) .rejects.toThrow() // Nothing was enqueued, so no later turn can carry it. @@ -732,7 +691,7 @@ describe('continuable review regressions', () => { // A cold resume is a new epoch: it must report its OWN answer, never the // previous epoch's, which the replayed transcript still contains. - await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again')) + await followup(ctx, parent, started.childId, message('again')) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(2) }) expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }]) @@ -746,11 +705,11 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) // Block the resumed prompt so this epoch produces nothing of its own. - ctx.on('agent/prompt-submit', async (subject, _content, _source, _signal, next) => { + ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => { if (subject === parent) return next() return { kind: 'block', reason: 'blocked by policy' } }) - await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again')) + await followup(ctx, parent, started.childId, message('again')) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(1) }) @@ -819,7 +778,7 @@ describe('continuable review regressions', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) // Queue a turn, then cancel so it is discarded rather than dequeued. The // Activation must still reach settlement instead of waiting on that id. - await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('discarded')) + await followup(ctx, parent, started.childId, message('discarded')) const drained = ctx.subagents.drainContinuable() hold.resolve(undefined) @@ -845,7 +804,7 @@ describe('continuable review regressions', () => { child.cancel({ kind: 'user' }) } }) - await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('doomed')) + await followup(ctx, parent, started.childId, message('doomed')) off() releaseFirst.resolve(undefined) @@ -861,7 +820,7 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) // Block admission so the child's only turn never opens. - ctx.on('agent/prompt-submit', async (subject, _content, _source, _signal, next) => { + ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => { if (subject === parent) return next() return { kind: 'block', reason: 'blocked by policy' } }) @@ -873,30 +832,35 @@ describe('continuable review regressions', () => { expect(ends[0]!.stopReason).toBe('completed') }) - it('never reports settled while an accepted message is still in the inbox', async () => { + it('retains the Activation while an accepted message is still in the inbox', async () => { const releaseFirst = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('first'), gate: releaseFirst.promise }, { chunks: textResponse('second') }, ]) const { ctx, parent } = await setupWith(adapter) - const states: (string | undefined)[] = [] + const registeredAtEnqueue: boolean[] = [] // A synchronous inbox observer runs before the admitting microtask, the // exact window where `Agent.status` is still idle. ctx.on('agent/inbox/enqueue', (agent) => { if (agent.session.header.parentSession !== undefined) { - states.push(ctx.subagents.activationState(agent.id)) + registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent) } }) const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('queued')) + const child = ctx.agents.get(started.childId) + await followup(ctx, parent, started.childId, message('queued')) - expect(states.length).toBeGreaterThan(0) - expect(states).not.toContain('settled') + expect(registeredAtEnqueue.length).toBeGreaterThan(0) + expect(registeredAtEnqueue).not.toContain(false) + expect(ctx.agents.get(started.childId)).toBe(child) releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) + expect(adapter.requests).toHaveLength(2) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'queued')).toBe(true) }) }) @@ -913,7 +877,7 @@ describe('continuable lifecycle observation', () => { await vi.waitFor(() => { expect(ends).toHaveLength(1) }) // A cold resume is a NEW epoch with its own pair. - await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again')) + await followup(ctx, parent, started.childId, message('again')) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(2) }) @@ -926,10 +890,19 @@ describe('continuable lifecycle observation', () => { }) describe('continuable public surface', () => { - it('exposes no cancellation, steering, or report operation', async () => { + it('exposes no host authority, residency query, cancellation, steering, or report operation', async () => { const { ctx } = await setup([]) const subagents: Record = ctx.subagents as unknown as Record - for (const absent of ['cancel', 'kill', 'steer', 'steerContinuable', 'report', 'resume']) { + for (const absent of [ + 'activationState', + 'cancel', + 'kill', + 'report', + 'resume', + 'steer', + 'steerContinuable', + 'userAuthority', + ]) { expect(subagents[absent]).toBeUndefined() } // No steering tool and no report tool are registered by this seam. @@ -957,7 +930,7 @@ describe('continuable public surface', () => { const controller = new AbortController() controller.abort('caller gave up') - await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('aborted'), controller.signal)) + await expect(followup(ctx, parent, started.childId, message('aborted'), controller.signal)) .rejects.toThrow() const loaded = await ctx.sessionPersistence.load(started.childId) @@ -975,7 +948,7 @@ describe('continuable public surface', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const controller = new AbortController() - await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('survives'), controller.signal) + await followup(ctx, parent, started.childId, message('survives'), controller.signal) // After acceptance the manager owns the Activation independently. controller.abort('caller gave up') @@ -1004,13 +977,13 @@ describe('continuable errors', () => { }).continuations manager.activations.delete(started.childId) - await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('hello'))) + await expect(followup(ctx, parent, started.childId, message('hello'))) .rejects.toThrow(SubagentError) expect(ctx.agents.get(started.childId)).toBe(child) hold.resolve(undefined) }) - it('rejects parent authority whose agent is no longer the live registry entry', async () => { + it('rejects a parent that is no longer the live registry entry', async () => { const { ctx, parent } = await setup([textResponse('first')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) const child = await vi.waitFor(() => { @@ -1021,7 +994,7 @@ describe('continuable errors', () => { // A stale parent reference: same id, not the exact live entry. const stale = { ...parent, id: parent.id } as unknown as Agent - await expect(followup(ctx, { kind: 'parent', agent: stale }, started.childId, message('stale'))) + await expect(followup(ctx, stale, started.childId, message('stale'))) .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) void child }) @@ -1127,7 +1100,7 @@ describe('continuable errors', () => { .toMatchObject({ agentProvider: 'mock', agentModel: 'child-model' }) // The resumed Activation runs on the declared route, not the parent's. - await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again')) + await followup(ctx, parent, started.childId, message('again')) await vi.waitFor(() => { expect(ctx.agents.get(started.childId)?.options.model).toBe('child-model') }) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 78d45eea47..64c510bf40 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -133,7 +133,7 @@ describe('SubagentService', () => { signal: new AbortController().signal, })).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) await expect(subagents.followup( - subagents.userAuthority(), + fakeParent(), SessionId('child'), [{ type: 'text', text: 'hello' }], { source: { kind: 'user' }, signal: new AbortController().signal }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 4fdd483ff7..e5330f43aa 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -46,8 +46,6 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", - "@deepseek-ai/dsh-tasks-local": "workspace:^", - "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index e8d2b1d4a1..db022d1c35 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -61,7 +61,7 @@ export function apply(ctx: Context): void { } const message: ContentBlock[] = [{ type: 'text', text: args.message }] const messageId = await ctx.subagents.followup( - { kind: 'parent', agent: parent }, + parent, SessionId(args.subagent_id), message, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45790d4591..69b16ee2e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5196,12 +5196,6 @@ importers: '@deepseek-ai/dsh-tasks': specifier: workspace:^ version: link:../../tasks/tasks - '@deepseek-ai/dsh-tasks-local': - specifier: workspace:^ - version: link:../../tasks/tasks-local - '@deepseek-ai/dsh-tool-tasks': - specifier: workspace:^ - version: link:../../tasks/tool-tasks '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index faaafc4c11..643af86aa6 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -160,13 +160,11 @@ export const LINK_MAP: Readonly> = { SkillSummary: 'skills.md', SaveTextSpill: 'spill.md', SpillRef: 'spill.md', - ActivationState: 'subagent.md', ContinuableCreateRequest: 'subagent.md', ContinuableCreateSpec: 'subagent.md', ContinuableStart: 'subagent.md', ContinuableStartSpec: 'subagent.md', CoordinatorMessageSource: 'subagent.md', - SubagentAuthority: 'subagent.md', SubagentFollowupOptions: 'subagent.md', SubagentProvider: 'subagent.md', SubagentRun: 'subagent.md', @@ -286,8 +284,8 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md', ResumeAgentOptions: 'agent resume contract is owned by packages/core/agent/README.md', SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts', - SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts', - SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts', + SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts', + SubagentRunInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts', TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts', WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e9b60aa650..9154fca93a 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1104,21 +1104,11 @@ "symbol": "SubagentFollowupOptions", "source": "packages/subagent/subagent/src/continuation.ts" }, - { - "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentAuthority", - "source": "packages/subagent/subagent/src/continuation.ts" - }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "ContinuableStart", "source": "packages/subagent/subagent/src/continuation.ts" }, - { - "doc": "docs/core-data-structures/subagent.md", - "symbol": "ActivationState", - "source": "packages/subagent/subagent/src/continuation.ts" - }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "ContinuableCreateRequest", From a91b20f6beacd4167d1bdc649e44e47eb86c712c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 21:21:14 +0800 Subject: [PATCH 068/114] fix(subagent): close continuation lifecycle gaps --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 12 +- ...8-continuable-subagent-conversations.zh.md | 12 +- ...subagent-continuation-operations.i18n.yaml | 4 +- ...-named-subagent-continuation-operations.md | 2 +- ...med-subagent-continuation-operations.zh.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 2 +- docs/config-catalog.md | 2 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- packages/hooks/hooks-claude/src/index.ts | 18 +- .../hooks-claude/tests/coverage-cases.ts | 18 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/continuation.ts | 204 +++++++++++------- packages/subagent/subagent/src/index.ts | 2 +- packages/subagent/subagent/src/invariant.ts | 8 +- packages/subagent/subagent/src/types.ts | 8 +- .../subagent/tests/continuation.spec.ts | 159 +++++++++++++- .../subagent/subagent/tests/invariant.spec.ts | 16 +- .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 2 +- .../tool-subagent-control/README.zh.md | 2 +- scripts/gen-doc-graphs.ts | 2 +- 29 files changed, 365 insertions(+), 142 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 70a926d303..879a8ecf22 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.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-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: df2aaa71dde4980bf2dd533c11254d0db8fe61b3 -2026-07-28-continuable-subagent-conversations.zh.md: 4437e73a3fa2f4d2043d2cfffe71259754fddeef +2026-07-28-continuable-subagent-conversations.md: e0119975d5f815886d671959efdc3028a5929f46 +2026-07-28-continuable-subagent-conversations.zh.md: fdf34d68260f70ef34682f0150a43aa1539dc767 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index df2aaa71dd..e0119975d5 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -36,11 +36,11 @@ The continuation manager owns activation admission, authority checks, the live o The named subagent provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its optional `prepareContinuable(request): Promise` method is the continuable-creation capability. The returned spec contains only detached provider-specific creation inputs such as the optional parent-history seed; it contains no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. The manager reserves the child identity, resolves the durable descriptor and common Agent setup, calls `ctx.agents.create()` through a private activation-owner scope, installs the returned `AgentHandle` into the Activation, establishes any continuable-parent ownership, and then calls `Agent.followup(initialPrompt)`. Inbox acceptance yields an `MessageId`; at that boundary `ctx.subagents.startContinuable()` returns `{ childId, messageId }` without waiting for the turn to start or for the message to enter the Session log. -Any failure before inbox acceptance rejects without returning either id. Agent creation provides rollback before handle transfer; after transfer, the manager disposes the created handle, removes the Activation, and rolls back any parent `ownedChildren` membership before rejecting. +Any failure before inbox acceptance rejects without returning either id. Agent creation provides rollback before handle transfer; after transfer, the manager keeps one closing transaction visible to concurrent delivery and drain, disposes the created handle, removes the Activation, and rolls back any parent `ownedChildren` membership before rejecting. Failure before the residency start edge publishes no terminal edge, while failure after a published start closes the lifecycle pair through normal disposal. `backgroundMode: 'one-shot' | 'continuable'` remains deployment policy. Configured continuable mode requires `prepareContinuable`; method presence replaces `SubagentProvider.resume?()` as the capability check, while a capable provider may still run one-shot work. -Cold resume does not dispatch through a subagent provider. The continuation manager folds the generic in-process descriptor, calls `ctx.agents.resume()` through the same activation-owner scope, installs the returned `AgentHandle`, and submits the waiting `next-turn`. `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent, and the initial provider name is not a recovery capability; remote providers require a separate design. +Cold resume does not dispatch through a subagent provider. The continuation manager folds the generic in-process descriptor, calls `ctx.agents.resume()` through the same activation-owner scope, installs the returned `AgentHandle`, and submits the waiting `next-turn`. `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. The initial provider name remains lifecycle provenance after that provider unregisters; it is not a recovery capability or a requirement for later residency. Remote providers require a separate design. `SubagentProvider.start()` and `SubagentRun` remain exclusively on the unchanged one-shot path. A continuable Activation directly owns its `AgentHandle` and never creates, wraps, or retains a `SubagentRun`; `SubagentRun.steer?()` is therefore absent. @@ -107,7 +107,7 @@ Child release occurs only after the child Agent is quiescent, every child of tha Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. -Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain. +Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Every materialized start and live delivery rechecks caller cancellation, draining, and Activation disposal in the same synchronous span as inbox submission, so teardown that begins before acceptance prevents delivery to the closing handle. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain. The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. The manager snapshots the live roots after closing admission, stops its outward lifecycle notifications before cancellation, and retains its internal ownership bookkeeping until every handle settles. Each Activation has one memoized disposal promise so host shutdown, manager unload, child release, and normal settlement can converge without double release. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining handles, and the aggregate drain reports failure after all branches settle. Durable child Sessions survive this process-local teardown. @@ -182,8 +182,8 @@ The implementation pins these behaviors: - A continuable child has at most one live Activation and one Agent inbox; the continuation manager has no Activation FIFO or queued Activation state. - `SubagentProvider.prepareContinuable?()` returns only a detached `ContinuableCreateSpec`; configured continuable mode requires that capability, while `backgroundMode` remains an independent policy choice. - The manager calls `ctx.agents.create()` through its private activation-owner scope, installs the returned `AgentHandle` and parent ownership, calls `Agent.followup(initialPrompt)`, and returns `{ childId, messageId }` when inbox acceptance yields the `MessageId`, without waiting for turn start or a Session-log write. -- Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership. -- Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through the initial subagent provider; `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. +- Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership through a closing transaction visible to concurrent delivery and drain; lifecycle publication failure emits no unmatched terminal edge. +- Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through or requires the initial subagent provider; the persisted provider name remains lifecycle provenance after provider removal, while `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. - A continuable Activation directly owns `AgentHandle` and never creates, wraps, or retains `SubagentRun`; `SubagentProvider.start()` and `SubagentRun` remain one-shot-only, without `SubagentRun.steer?()`. - `followup()` accepts only the exact live direct parent; durable message provenance cannot authorize delivery. - Continuation messages always use `Agent.followup()` and share its inbox FIFO, including when the child already has an open turn. @@ -198,7 +198,7 @@ The implementation pins these behaviors: - This version exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. - Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. - No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. -- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance failure, caller-signal ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. +- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, provider-independent cold resume, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. - Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools. - A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 4437e73a3f..fdf34d6826 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -36,11 +36,11 @@ persisted Session 具名 subagent 提供方只参与准备初始创建规格,此时 `spawn` 与 `fork` 有所区别。其可选的 `prepareContinuable(request): Promise` 方法就是可继续创建能力。返回的规格只包含与 Agent 实例分离且由提供方决定的创建输入,例如可选的 parent 历史种子;它不包含 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作。管理器会预留 child 身份,解析持久化描述符和通用 Agent 配置,通过私有 activation-owner 作用域调用 `ctx.agents.create()`,将返回的 `AgentHandle` 安装到激活中,建立适用的可继续 parent 所有权,然后调用 `Agent.followup(initialPrompt)`。inbox 接受消息后会产生一个 `MessageId`;`ctx.subagents.startContinuable()` 在此边界返回 `{ childId, messageId }`,不等待轮次开始,也不等待消息写入会话日志。 -inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的情况下被拒绝。Agent 创建流程负责 handle 移交前的回滚;移交后,管理器会先 dispose 已创建的 handle、移除激活并回滚 parent `ownedChildren` 中的任何成员关系,再拒绝操作。 +inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的情况下被拒绝。Agent 创建流程负责 handle 移交前的回滚;移交后,管理器会保留一个对并发投递和 drain 可见的关闭事务,dispose 已创建的 handle、移除激活并回滚 parent `ownedChildren` 中的任何成员关系,再拒绝操作。在驻留 start 事件发布前失败不会发布终止事件,start 发布后失败则通过正常 dispose 闭合生命周期配对。 `backgroundMode: 'one-shot' | 'continuable'` 仍是部署策略。配置为 continuable 时要求存在 `prepareContinuable`;该方法是否存在会取代 `SubagentProvider.resume?()` 成为能力检查,而具备该能力的提供方仍可运行 one-shot 工作。 -冷恢复不会通过 subagent 提供方分发。继续执行管理器会归并通用的进程内描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,安装返回的 `AgentHandle`,并提交等待中的 `next-turn`。`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在,初始提供方名称也不是恢复能力;远程提供方需要单独设计。 +冷恢复不会通过 subagent 提供方分发。继续执行管理器会归并通用的进程内描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,安装返回的 `AgentHandle`,并提交等待中的 `next-turn`。`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。初始提供方注销后,其名称仍作为生命周期来源信息保留;它不是恢复能力,也不是后续驻留的必要条件。远程提供方需要单独设计。 `SubagentProvider.start()` 和 `SubagentRun` 只保留在不变的 one-shot 路径上。可继续激活直接持有自身的 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;因此,`SubagentRun.steer?()` 不存在。 @@ -107,7 +107,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 -顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。 +顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、draining 和激活 dispose,因此在接受前开始的拆卸会阻止向正在关闭的 handle 投递。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。 activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。管理器在关闭准入后对在线根节点创建快照,在取消前停止自身的对外生命周期通知,并保留内部所有权簿记,直至每个 handle 都结算。每次激活有一个记忆化的 dispose promise,使宿主关闭、管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余 handle,聚合 drain 则在所有分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。 @@ -182,8 +182,8 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - 可继续 child 至多拥有一个在线激活和一个 Agent inbox;继续执行管理器没有激活 FIFO 或 queued 激活状态。 - `SubagentProvider.prepareContinuable?()` 只返回分离式 `ContinuableCreateSpec`;配置为 continuable 时要求具备该能力,而 `backgroundMode` 仍是独立的策略选择。 - 管理器通过私有 activation-owner 作用域调用 `ctx.agents.create()`,安装返回的 `AgentHandle` 并建立 parent 所有权,调用 `Agent.followup(initialPrompt)`,然后在 inbox 接受消息并产生 `MessageId` 时返回 `{ childId, messageId }`,而不等待轮次开始或消息写入会话日志。 -- 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系。 -- 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过初始 subagent 提供方分发;`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。 +- 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并通过一个对并发投递和 drain 可见的关闭事务回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系;生命周期发布失败不会产生无配对的终止事件。 +- 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过或依赖初始 subagent 提供方;提供方移除后,持久化的提供方名称仍作为生命周期来源信息保留,且 `SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。 - 可继续激活直接持有 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;`SubagentProvider.start()` 和 `SubagentRun` 只用于 one-shot,且没有 `SubagentRun.steer?()`。 - `followup()` 只接受确切的在线直接 parent;持久化消息来源信息不能授权投递。 - 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括 child 已有开放轮次的情况。 @@ -198,7 +198,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - 本版本不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 - 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 - 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 -- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前失败路径的完整回滚、接受前后两个阶段的调用方 signal 所有权,以及已接受但未写入日志的消息不会自动回放。 +- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、不依赖提供方的冷恢复、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。 - 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 - 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml index a8b249fae0..ad2e2c40bd 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.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-27-intent-named-subagent-continuation-operations.md -2026-07-27-intent-named-subagent-continuation-operations.md: 9f29074add3517d0baf94516c56fa69085ef75c4 -2026-07-27-intent-named-subagent-continuation-operations.zh.md: a748af1a6cf44bc552b492d43314bf5a4e95338d +2026-07-27-intent-named-subagent-continuation-operations.md: 5029d8335f699e99e67c6027b7d1666880db4724 +2026-07-27-intent-named-subagent-continuation-operations.zh.md: 0785730c1934a192380af41f3ad88f95a2747cf7 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md index 9f29074add..5029d8335f 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md) -The `followup` operation this record names is retained by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed return value with the accepted `MessageId`, its bare-`Agent` parameter with an explicit authority union, and provider `resume` dispatch with `prepareContinuable`. +The `followup` operation this record names is retained by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed return value with the accepted `MessageId`, retains its bare `Agent` parameter as exact live-direct-parent authority, and replaces provider `resume` dispatch with `prepareContinuable`. ## Problem diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index a748af1a6c..0785730c19 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文 -本记录命名的 `followup` 操作由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)保留,但后者以已接受的 `MessageId` 替换其基于 Task 的返回值,以显式的 authority(授权)联合类型替换裸 `Agent` 参数,并以 `prepareContinuable` 替换提供方 `resume` 派发。 +本记录命名的 `followup` 操作由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)保留,但后者以已接受的 `MessageId` 替换其基于 Task 的返回值,保留裸 `Agent` 参数作为准确的实时直属父级权限,并以 `prepareContinuable` 替换提供方 `resume` 派发。 ## 问题 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 2d14dedd68..576ef05d32 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 44be3b55ab5061490a2ceb632175c1bb53a21330 -architecture.zh.md: d803bea1ba39e8fd07a01446dd2d2ae53aca35e1 +architecture.md: 6aa942ba2702d8d30ae94d9968f07abb5e1fe88d +architecture.zh.md: c8aaa68527f34f4879f882a08260a4e0bd4f4c5f diff --git a/docs/architecture.md b/docs/architecture.md index 44be3b55ab..6aa942ba27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry, progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction, optional model-free result pruning | -| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers plus optional Task-backed continuation and steer-or-resume routing | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers and Activation-based continuations | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index d803bea1ba..c8aaa68527 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -38,7 +38,7 @@ | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill(技能)提供方注册表和渐进式披露 | | `ctx.web` | [`web/`](../packages/web/README.md) | 搜索与抓取提供方注册表 | | `ctx.compact`,`ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction)和可选的无模型结果裁剪 | -| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方,以及可选的由 Task 支撑的继续执行与 steer-or-resume 路由 | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方和由 Activation 支撑的继续执行 | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | 落日志的 plan 协作状态 | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2681bd728a..561b59e10e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -391,7 +391,7 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Task-backed continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9b7be60536..420b0f4b66 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -503,7 +503,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:46`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:45`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 6a66f6d498..39393ba161 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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/subagent.md -subagent.md: 81d09903bec3bd4767e73720be4a8d58c7837eb4 -subagent.zh.md: 6fd6845b5ceedd81e02365680a534e6d0c726aaf +subagent.md: e160c596acb55f0e94cba84b8c79355c966eb51a +subagent.zh.md: 6b934a523fa0ea5d53ea9a670e56b72b7f785593 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 81d09903be..e160c596ac 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -341,7 +341,7 @@ interface SubagentProvider { } ``` -Provider `start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. Each continuable Activation emits the same observe-only pair for its residency epoch, so a cold resume is a new epoch with its own `runId`. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. +Provider `start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. Each continuable Activation emits the same observe-only pair for its residency epoch, so a cold resume is a new epoch with its own `runId`. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. Their `provider` field is provenance for the run or Activation epoch, not a claim that the provider remains registered when the edge is emitted. ## In-process backends: depth and seed diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 6fd6845b5c..6b934a523f 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -343,7 +343,7 @@ interface SubagentProvider { } ``` -提供方的 `start()` 仅在 run 就绪时 fulfill。服务铸造唯一的 `runId`,从提供方确切的 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。每个可继续 Activation 都会为其驻留纪元 emit 相同的仅观察事件对,因此一次冷恢复就是一段拥有自己 `runId` 的新纪元。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,且会隔离各自的 listener 异常。 +提供方的 `start()` 仅在 run 就绪时 fulfill。服务铸造唯一的 `runId`,从提供方确切的 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。每个可继续 Activation 都会为其驻留纪元 emit 相同的仅观察事件对,因此一次冷恢复就是一段拥有自己 `runId` 的新纪元。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,且会隔离各自的 listener 异常。其中的 `provider` 字段是 run 或 Activation 时段的来源信息,并不声明该 edge 发出时提供方仍处于注册状态。 ## 进程内后端:深度与种子 diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 8552598818..39a4ce1dc3 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -31,10 +31,9 @@ import { type MatcherGroup, type MergedHookOutcome, } from '@deepseek-ai/dsh-hook-protocol' -// Side-effect type import: pulls in the `subagent/start` + `subagent/end` event -// declarations (declaration-merged into cordis `Events` by dsh-subagent) so the -// SubagentStart/SubagentStop listeners below type-check. -import type {} from '@deepseek-ai/dsh-subagent' +// Pulls in the declaration-merged subagent events and the identity pairing their +// start/end edges. +import type { SubagentRunId } from '@deepseek-ai/dsh-subagent' import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts' export const name = 'hooks-claude' @@ -119,6 +118,10 @@ export function apply(ctx: Context, config: Config): void { // Emit-shaped points run detached, so track their chains; disposal aborts // active hooks and drains continuations before resolving. const detached = createDetachedRuns() + // Only the start edge guarantees registry access. Retain each local child + // through its paired end so stop hooks keep the session workspace after the + // handle unregisters the agent. + const subagentChildren = new Map() ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs') /** @@ -276,6 +279,7 @@ export function apply(ctx: Context, config: Config): void { // use the live child's workspace and the generic agent-type matcher subject. ctx.on('subagent/start', (info) => { const child = ctx.get('agents')?.get(info.id) + if (child !== undefined) subagentChildren.set(info.runId, child) detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) @@ -284,10 +288,8 @@ export function apply(ctx: Context, config: Config): void { .catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) })) }) ctx.on('subagent/end', (info) => { - // Look up the child (still recoverable: `subagent/end` fires from the service's detached - // `.then` before the tool caller's `await run.result` disposes it) so the hook runs in the - // child's cwd, not the server default. - const child = ctx.get('agents')?.get(info.id) + const child = subagentChildren.get(info.runId) ?? ctx.get('agents')?.get(info.id) + subagentChildren.delete(info.runId) detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })) }) } diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index f9f5d1b088..57abe9c101 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -681,12 +681,11 @@ export function defineCoverageCases(group: CoverageGroup): void { }) it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => { - // `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint` - // receives that agent and runs in the child's cwd rather than the executor default. const serverDir = dir() const childDir = dir() const marker = join(childDir, 'stopwhere') - hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] }) + const payload = join(childDir, 'stoppayload') + hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'cat > stoppayload.tmp; mv stoppayload.tmp stoppayload; pwd > stopwhere' }] }] }) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) @@ -698,15 +697,22 @@ export function defineCoverageCases(group: CoverageGroup): void { const { SessionId } = await import('@deepseek-ai/dsh-session') const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' }) + const runId = SubagentRunId('run-stop') + const identity = { runId, provider: 'inproc', id: childHandle.agent.id, local: true } + // Start is the registry-backed capture edge; end deliberately follows + // handle disposal, matching continuable Activation settlement. + ctx.emit(subagentCarrier(ctx), 'subagent/start', identity) + await childHandle.dispose() + expect(ctx.agents.get(childHandle.agent.id)).toBeUndefined() + ctx.emit(subagentCarrier(ctx), 'subagent/end', { ...identity, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir - const { readFileSync } = await import('node:fs') const where = readFileSync(marker, 'utf8').trim() + const input = JSON.parse(readFileSync(payload, 'utf8')) as { cwd: string; session_id: string } // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. expect(where.endsWith(childDir.split('/').pop()!)).toBe(true) - await childHandle.dispose() + expect(input).toMatchObject({ cwd: childDir, session_id: childHandle.agent.id }) }) }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 23bc8500fc..b8ed113445 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 1b38d493efa1dbe86464ad376649ff37914067da -README.zh.md: ec907f466779fc5c8a503f003a50f4aaf41c8b49 +README.md: 0e59a1ad5f256de4d6505d3d00d3790d7738a457 +README.zh.md: 073b4903520544e1b5b9209f792aa5e05d9334b0 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 1b38d493ef..0e59a1ad5f 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -80,7 +80,7 @@ A continuation-managed parent Activation records each child Session id in an `ow ## Lifecycle events -The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. +The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. The `provider` field is lifecycle provenance rather than a live-registry claim: an accepted one-shot run may become ready after provider removal, and a cold-resumed epoch retains its descriptor's initial provider name without requiring that provider to be registered. Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index ec907f4667..073b490352 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -80,7 +80,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 生命周期事件 -服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 +服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。`provider` 字段是生命周期来源信息,而非提供方仍在注册的声明:已接受的一次性 run 可在提供方移除后才进入就绪状态,冷恢复时段也会保留描述符中的初始提供方名称,而不要求该提供方仍处于注册状态。 运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 072e286b36..109c5b666f 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -36,6 +36,7 @@ import { resolveChildAgentOptions, resolveChildDepth, } from './child-agent.ts' +import { assertSubagentMaxDepth } from './depth.ts' import { seedDescriptorTurn } from './descriptor-seed.ts' import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts' import type { ActivationObserver } from './lifecycle.ts' @@ -248,6 +249,7 @@ export class SubagentContinuationManager { this.requirePersistence() const request = spec.request const parent = request.parent + assertSubagentMaxDepth(request.maxDepth) const childId = SessionId(randomUUID()) const childDepth = resolveChildDepth(parent, request.maxDepth) // Snapshot before any await: invalid descriptor JSON rejects the call @@ -282,11 +284,13 @@ export class SubagentContinuationManager { composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, }) - // Materialization published the Activation; an abort landing in that - // window — a `subagent/start` listener can cancel synchronously — must - // roll the child back instead of opening its first turn. - await this.rollbackIfAborted(activation, spec.signal) - return this.submit(activation, request.prompt, { kind: 'user' }, parent) + return this.submitMaterialized( + activation, + request.prompt, + { kind: 'user' }, + parent, + spec.signal, + ) }) return { childId, messageId } } @@ -328,13 +332,8 @@ export class SubagentContinuationManager { if (activation.disposal !== undefined) { return activation.disposal.then(() => undefined, () => undefined) } - await this.authorizeLive(parent, activation) - // The caller signal owns admission until acceptance, so re-check it - // here: the outer check cannot cover an abort that landed while - // authorization yielded, and enqueueing afterwards would return a - // message id for a delivery the caller already cancelled. - options.signal.throwIfAborted() - return this.submit(activation, content, options.source, parent) + this.authorizeLive(parent, activation) + return this.submitAdmitted(activation, content, options.source, parent, options.signal) }) /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that * race reaches the retry below, which then cold-resumes a new Activation. */ @@ -455,23 +454,33 @@ export class SubagentContinuationManager { composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, signal: options.signal, }) - await this.rollbackIfAborted(activation, options.signal) - return this.submit(activation, content, options.source, parent) + return this.submitMaterialized(activation, content, options.source, parent, options.signal) } /** - * Dispose a freshly materialized Activation when the caller signal won the - * handoff between publication and inbox acceptance, so an aborted operation - * never leaves a resident child. - * @param activation - the just-published Activation. - * @param signal - the caller signal owning admission until acceptance. + * Submit to a freshly materialized Activation or roll it back completely. + * @param activation - the just-published Activation to admit or release. + * @param content - the initial or resumed message content. + * @param source - durable provenance for the accepted message. + * @param parent - the live direct parent authorizing admission. + * @param signal - caller cancellation owning admission until acceptance. + * @returns the accepted inbox message id. */ - private async rollbackIfAborted(activation: Activation, signal: AbortSignal): Promise { - if (!signal.aborted) return - /* v8 ignore next -- the swallow only covers a disposal fault during rollback, which - * must not mask the caller's abort as the operation's failure. */ - await this.dispose(activation).catch(() => undefined) - signal.throwIfAborted() + private async submitMaterialized( + activation: Activation, + content: ContentBlock[], + source: MessageSource, + parent: Agent, + signal: AbortSignal, + ): Promise { + try { + return this.submitAdmitted(activation, content, source, parent, signal) + } catch (error: unknown) { + /* v8 ignore next -- rollback disposal failures must not mask the + * pre-acceptance signal, drain, or lifecycle failure. */ + await this.dispose(activation).catch(() => undefined) + throw error + } } /** @@ -498,30 +507,24 @@ export class SubagentContinuationManager { inputs.signal.throwIfAborted() const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) } const observer = this.host.observeActivation(provider, childId, parent) - let handle: AgentHandle - try { - const { create } = inputs - handle = create === undefined - ? await this.ownerCtx.agents.resume({ - resumeSessionId: childId, - agentOptions: inputs.agentOptions, - signal: inputs.signal, - setup, - }) - : await this.ownerCtx.agents.create({ - sessionId: childId, - meta: create.meta, - seed: create.seed, - agentOptions: inputs.agentOptions, - signal: inputs.signal, - setup, - }) - } catch (error: unknown) { - // Agent creation provides rollback before handle transfer, so nothing - // outlives this rejection; report the epoch that never became resident. - // No start edge was published, so this epoch has no lifecycle to close. - throw error - } + const { create } = inputs + // Agent creation owns rollback before handle transfer. A rejection leaves + // no resident Activation and therefore publishes no lifecycle edge. + const handle: AgentHandle = create === undefined + ? await this.ownerCtx.agents.resume({ + resumeSessionId: childId, + agentOptions: inputs.agentOptions, + signal: inputs.signal, + setup, + }) + : await this.ownerCtx.agents.create({ + sessionId: childId, + meta: create.meta, + seed: create.seed, + agentOptions: inputs.agentOptions, + signal: inputs.signal, + setup, + }) const activation: Activation = { childId, @@ -540,42 +543,53 @@ export class SubagentContinuationManager { inputs.signal.throwIfAborted() this.assertAdmitting() this.acquireOwnership(parent, childId) + // Every accepted id leaves the inbox exactly once, through dequeue or + // discard. Clearing it there is what lets `stateOf()` distinguish a truly + // quiet Agent from one whose accepted turn has not been admitted yet. + // Registered through the child's own scoped context, so scope filtering + // already restricts both listeners to this exact agent. + handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => { + /* v8 ignore next -- a dequeue of an id this manager never admitted needs + * another sender on the same child, which no current path allows. */ + if (activation.accepted.delete(item.message.id)) this.wake(activation) + }) + handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => { + // Deleting every id in the batch is unconditional; waking once afterwards + // costs nothing and avoids branching on which ids this manager admitted. + for (const item of items) activation.accepted.delete(item.message.id) + this.wake(activation) + }) + // Resident: publish the start edge before any turn can run, so observers + // see this epoch before its first request. + observer.start(handle.agent) } catch (error: unknown) { - // Roll the transfer back completely: the Activation leaves the map, the - // parent's ownership membership is released, and the created handle is - // disposed before this rejection surfaces. No lifecycle edge is published, - // because `observer.start()` below has not run for this epoch. - this.activations.delete(childId) - this.releaseOwnership(childId) - activation.disposal = handle.dispose() - /* v8 ignore next -- the created handle disposes cleanly on every rollback this - * transaction can reach; the catch only keeps a disposal fault from masking `error`. */ - await activation.disposal.catch(() => undefined) + // Listener exceptions are contained by the lifecycle emitter; a start + // publication throw therefore leaves no residency edge to pair. + /* v8 ignore next -- rollback failure must not mask the admission failure + * that prevented this operation from returning an accepted message id. */ + await this.rollbackUnpublished(activation).catch(() => undefined) throw error } - // Every accepted id leaves the inbox exactly once, through dequeue or - // discard. Clearing it there is what lets `stateOf()` distinguish a truly - // quiet Agent from one whose accepted turn has not been admitted yet. - // Registered through the child's own scoped context, so scope filtering - // already restricts both listeners to this exact agent. - handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => { - /* v8 ignore next -- a dequeue of an id this manager never admitted needs - * another sender on the same child, which no current path allows. */ - if (activation.accepted.delete(item.message.id)) this.wake(activation) - }) - handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => { - // Deleting every id in the batch is unconditional; waking once afterwards - // costs nothing and avoids branching on which ids this manager admitted. - for (const item of items) activation.accepted.delete(item.message.id) - this.wake(activation) - }) - // Resident: publish the start edge before any turn can run, so observers - // see this epoch before its first request. - observer.start(handle.agent) this.watchSettlement(activation) return activation } + /** + * Release an Activation whose start edge was not published. The memoized + * transaction remains in the live map until handle disposal settles, so a + * concurrent drain or delivery observes the same closing boundary. + */ + private rollbackUnpublished(activation: Activation): Promise { + return (activation.disposal ??= (async () => { + try { + await activation.handle.dispose() + } finally { + this.activations.delete(activation.childId) + this.releaseOwnership(activation.childId) + } + })()) + } + /** * Register the child in a continuation-managed parent's owned set before the * child can run, so that parent cannot settle while the child is live. A @@ -637,12 +651,36 @@ export class SubagentContinuationManager { return message.id } + /** + * Cross the final admission cutoff and submit without yielding. Signal abort, + * manager drain, or Activation disposal that wins before this synchronous + * span rejects without inbox acceptance. + */ + private submitAdmitted( + activation: Activation, + content: ContentBlock[], + source: MessageSource, + parent: Agent, + signal: AbortSignal, + ): MessageId { + signal.throwIfAborted() + this.assertAdmitting() + /* v8 ignore next 6 -- only a synchronous re-entrant disposer can change + * this field between the caller's live check and this no-await boundary. */ + if (disposalOf(activation) !== undefined) { + throw new SubagentError( + `subagent "${activation.childId}" activation is being disposed; the message was not accepted`, + 'ACTIVATION_CLOSING', + ) + } + return this.submit(activation, content, source, parent) + } + /** * Authorize delivery to a live Activation. A parent must be the exact live * direct parent recorded in the child's durable header. */ - private async authorizeLive(parent: Agent, activation: Activation): Promise { - await Promise.resolve() + private authorizeLive(parent: Agent, activation: Activation): void { this.authorizeLineage( parent, activation.childId, @@ -762,6 +800,12 @@ export class SubagentContinuationManager { // Capture the child-dependent edge data while the child is still live: // handle disposal unregisters it, and consumers read its log and scope. activation.observer.capture(activation.handle.agent) + } catch (error: unknown) { + failure ??= new SubagentError( + `subagent "${childId}" activation teardown failed: ${errorChain(error)}`, + 'ACTIVATION_TEARDOWN_FAILED', + { cause: error }, + ) } finally { try { await activation.handle.dispose() diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 5d66ff8da5..cf07d4aff1 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -303,7 +303,7 @@ export class SubagentService extends Service { return provider } - /** Resolve the optional Task-backed continuation runtime or fail loud. */ + /** Resolve the optional continuable-subagent manager or fail loud. */ private requireContinuations(): SubagentContinuationManager { if (this.continuations === undefined) { throw new SubagentError( diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index dd224b68de..13eafddf3d 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -43,9 +43,11 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant } if (eventName === 'subagent/start') { const info = args[0] as SubagentRunInfo - if (!providers.has(info.provider)) fail(`subagent/start names inactive provider ${JSON.stringify(info.provider)}`) - if (String(info.runId).length === 0 || String(info.id).length === 0) { - fail('subagent/start runId and child id must be non-empty') + // Provider availability is an admission-time relationship. A ready + // one-shot run may outlive provider removal, and a cold-resumed Activation + // carries durable provider provenance without dispatching through it. + if (info.provider.length === 0 || String(info.runId).length === 0 || String(info.id).length === 0) { + fail('subagent/start provider, runId, and child id must be non-empty') } if (runs.has(info.runId)) fail(`subagent/start repeated run id ${JSON.stringify(info.runId)}`) stagedStarts.add(info) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 5c703c6ce5..aff85acd6a 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -35,7 +35,11 @@ export function SubagentRunId(id: string): SubagentRunId { export interface SubagentRunInfo { /** Unique identity shared with the paired terminal event. */ readonly runId: SubagentRunId - /** The provider that established the run. */ + /** + * Provider provenance for this run or Activation epoch. The named provider + * may be absent when an accepted run becomes ready or a persisted Activation + * cold-resumes, because neither lifecycle depends on continued registration. + */ readonly provider: string /** The child agent's id. */ readonly id: SessionId @@ -50,7 +54,7 @@ export interface SubagentRunInfo { export interface SubagentRunEndInfo { /** Unique identity shared with the paired start event. */ readonly runId: SubagentRunId - /** The provider that ran it. */ + /** The same provider provenance carried by the paired start event. */ readonly provider: string /** The child agent's id. */ readonly id: SessionId diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 02dd7896ca..4206ce299d 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -14,12 +14,14 @@ import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' +import InvariantService from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService, { SubagentError, SUBAGENT_DESCRIPTOR_VERSION, } from '../src/index.ts' import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts' +import * as SubagentInvariant from '../src/invariant.ts' type Script = ConstructorParameters[0] @@ -228,6 +230,24 @@ describe('SubagentService.startContinuable', () => { }) }) + it('rolls an unpublished Activation back when lifecycle publication fails', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', info => void ends.push(info)) + ctx.on('internal/dispatch', (_mode, eventName) => { + if (eventName === 'subagent/start') throw new Error('start publication failed') + }, { global: true }) + + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toThrow(/start publication failed/) + + await vi.waitFor(() => { + expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) + }) + expect(ends).toEqual([]) + await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined() + }) + it('rejects a continuable child that would exceed the configured depth cap', async () => { const { ctx, parent } = await setup([]) await expect(ctx.subagents.startContinuable({ @@ -237,6 +257,15 @@ describe('SubagentService.startContinuable', () => { expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) }) + it('rejects an invalid continuable depth cap before provider preparation', async () => { + const { ctx, parent } = await setup([]) + await expect(ctx.subagents.startContinuable({ + ...startSpec(parent), + request: { prompt: message('deep'), parent, maxDepth: Number.NaN }, + })).rejects.toThrow(/non-negative safe integer/) + expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) + }) + it('omits undeclared composition fields from the descriptor', async () => { const { ctx } = await setup([]) // A routeless parent declares no provider/model, and this start declares no @@ -402,6 +431,38 @@ describe('SubagentService.followup residency routing', () => { expect(loaded.events.filter(event => event.type === 'subagent/descriptor')).toHaveLength(1) }) + it('cold-resumes after the initial provider unregisters', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')]) + await ctx.plugin(InvariantService) + await ctx.plugin(SubagentInvariant) + const disposeProvider = ctx.subagents.registerProvider({ + name: 'retired', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async () => { throw new Error('one-shot start is not used') }, + prepareContinuable: () => Promise.resolve({}), + }) + const starts: SubagentRunInfo[] = [] + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/start', info => void starts.push(info)) + ctx.on('subagent/end', info => void ends.push(info)) + + const started = await ctx.subagents.startContinuable(startSpec(parent, 'retired')) + await waitNoActivation(ctx, started.childId) + disposeProvider() + expect(ctx.subagents.getProvider('retired')).toBeUndefined() + + await expect(followup(ctx, parent, started.childId, message('continue without provider'))) + .resolves.toBeTypeOf('string') + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { expect(ends).toHaveLength(2) }) + + expect(starts.map(info => info.provider)).toEqual(['retired', 'retired']) + expect(ends.map(info => info.runId)).toEqual(starts.map(info => info.runId)) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(userTexts(loaded.events)).toEqual(['child task', 'continue without provider']) + }) + it('wakes a waiting Activation instead of cold-resuming it', async () => { const releaseGrandchild = Promise.withResolvers() const adapter = new GatedAdapter([ @@ -615,6 +676,48 @@ describe('continuable durability and teardown', () => { .rejects.toMatchObject({ code: 'DRAINING' }) }) + it('rejects an initial prompt when drain starts after materialization', async () => { + const { ctx, parent } = await setup([]) + const drains: Promise[] = [] + const accepted: MessageId[] = [] + ctx.on('subagent/start', () => { drains.push(ctx.subagents.drainContinuable()) }) + ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) }) + + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toMatchObject({ code: 'DRAINING' }) + await Promise.all(drains) + + expect(accepted).toEqual([]) + expect(ctx.agents.list()).toEqual([parent]) + }) + + it('admits a live follow-up before a later drain can begin disposal', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const order: string[] = [] + child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => { + if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) { + order.push('enqueue') + } + }) + child.ctx.on('agent/cancel-requested', () => { order.push('cancel') }) + + const delivery = followup(ctx, parent, started.childId, message('before drain')) + // Let the child-lock operation reach the live admission cutoff. Admission + // and inbox submission must then complete in one synchronous span. + await Promise.resolve() + const drained = ctx.subagents.drainContinuable() + hold.resolve(undefined) + + await expect(delivery).resolves.toBeTypeOf('string') + await drained + expect(order).toEqual(['enqueue', 'cancel']) + }) + it('has no automatic replay for an accepted but unlogged message', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('first'), gate: hold.promise }]) @@ -744,6 +847,29 @@ describe('continuable review regressions', () => { expect(ends[0]!.stopReason).toBe('error') }) + it('reports a pre-disposal teardown failure on the terminal edge', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', info => void ends.push(info)) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const manager = (ctx.subagents as unknown as { + continuations: { + activations: Map void } }> + } + }).continuations + const activation = manager.activations.get(started.childId)! + activation.observer.capture = () => { throw new Error('capture failed') } + + const drained = ctx.subagents.drainContinuable() + hold.resolve(undefined) + await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + expect(ends[0]!.stopReason).toBe('error') + }) + it('cancels a running turn before the final durability checkpoint', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('slow'), gate: hold.promise }]) @@ -797,8 +923,8 @@ describe('continuable review regressions', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! - // Cancel from the synchronous enqueue observer: the discard fires before - // `followup()` returns, so the id is discarded before it can be recorded. + // Cancel from the synchronous enqueue observer: the discard fires after the + // id is recorded but before `followup()` returns. const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => { if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) @@ -815,6 +941,35 @@ describe('continuable review regressions', () => { expect(hasUserText(loaded.events, 'doomed')).toBe(false) }) + it('releases older ids discarded during a later admission window', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const manager = (ctx.subagents as unknown as { + continuations: { + activations: Map }> + } + }).continuations + const activation = manager.activations.get(started.childId)! + + await followup(ctx, parent, started.childId, message('queued')) + expect(activation.accepted.size).toBe(1) + const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => { + if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { + child.cancel({ kind: 'user' }) + } + }) + await followup(ctx, parent, started.childId, message('doomed')) + off() + + expect(activation.accepted.size).toBe(0) + releaseFirst.resolve(undefined) + await waitNoActivation(ctx, started.childId) + }) + it('reports completed when no ordinary turn closed', async () => { const { ctx, parent } = await setup([]) const ends: SubagentRunEndInfo[] = [] diff --git a/packages/subagent/subagent/tests/invariant.spec.ts b/packages/subagent/subagent/tests/invariant.spec.ts index ac3a919862..e288b77a86 100644 --- a/packages/subagent/subagent/tests/invariant.spec.ts +++ b/packages/subagent/subagent/tests/invariant.spec.ts @@ -68,10 +68,10 @@ describe('subagent invariants', () => { it('rejects malformed and unpaired run transitions', async () => { const ctx = await setup() - expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/inactive provider/) - ctx.emit('subagent/provider-added', provider('mock')) + expect(() => { emitRun(ctx, 'subagent/start', start({ provider: '' })) }) + .toThrow(/provider, runId, and child id must be non-empty/) expect(() => { emitRun(ctx, 'subagent/start', start({ runId: SubagentRunId('') })) }) - .toThrow(/runId and child id must be non-empty/) + .toThrow(/provider, runId, and child id must be non-empty/) emitRun(ctx, 'subagent/start', start()) expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/repeated run id/) expect(() => { emitRun(ctx, 'subagent/end', end({ runId: SubagentRunId('missing') })) }) @@ -79,4 +79,14 @@ describe('subagent invariants', () => { expect(() => { emitRun(ctx, 'subagent/end', end({ id: SessionId('other') })) }) .toThrow(/identity diverges/) }) + + it('accepts historical provider provenance after registration ends', async () => { + const ctx = await setup() + const historical = provider('historical') + ctx.emit('subagent/provider-added', historical) + ctx.emit('subagent/provider-removed', historical.name) + + emitRun(ctx, 'subagent/start', start({ provider: historical.name })) + emitRun(ctx, 'subagent/end', end({ provider: historical.name })) + }) }) diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index c717ced0a2..f8ffb0af2c 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/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/subagent/tool-subagent-control/README.md -README.md: b62870217e0eaf57c1cd16204c703aada694d4f2 -README.zh.md: 24a4b7b69a2f95533e4f0b963156fce0aad46bf4 +README.md: 5023862cba39769248a9f6cbe935d6397df39266 +README.zh.md: a5812704609edd38aedc344b4c64044fbf32c8a8 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index b62870217e..5023862cba 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.followup()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work. -The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It supplies exact live parent authority (`{ kind: 'parent', agent }`) from `exec.agent` and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. The child does not reply to the sender — its transcript by that id is the source of what it did. A delivery failure becomes an errored tool result stating the message was not delivered. +The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. The child does not reply to the sender — its transcript by that id is the source of what it did. A delivery failure becomes an errored tool result stating the message was not delivered. ## Model Experience diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md index 24a4b7b69a..a581270460 100644 --- a/packages/subagent/tool-subagent-control/README.zh.md +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -4,7 +4,7 @@ 可选的全局具名 `send_message` 工具:`ctx.subagents.followup()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。 -本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它从 `exec.agent` 提供准确的实时父级权限(`{ kind: 'parent', agent }`),并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。子 agent 不会回复发送方——通过该 id 查看其 transcript 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。 +本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的准确实时父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。子 agent 不会回复发送方——通过该 id 查看其 transcript 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。 ## 模型体验 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index ed46fd687d..2bbfe56b2c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -428,7 +428,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'], consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], - note: 'Providers implement transports; the service also owns optional Task-backed continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', + note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, { key: 'tasks', From 8f3613c4b7d34f4c4ad482f0e73fe053effcb4aa Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 21:51:31 +0800 Subject: [PATCH 069/114] fix(subagent): close final continuation races --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 12 +-- ...8-continuable-subagent-conversations.zh.md | 12 +-- packages/hooks/hooks-claude/src/index.ts | 3 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 4 +- packages/subagent/subagent/README.zh.md | 4 +- .../subagent/subagent/src/continuation.ts | 72 ++++++++------ .../subagent/tests/continuation.spec.ts | 97 +++++++++++++++++++ 9 files changed, 162 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 879a8ecf22..816700d7e7 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.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-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: e0119975d5f815886d671959efdc3028a5929f46 -2026-07-28-continuable-subagent-conversations.zh.md: fdf34d68260f70ef34682f0150a43aa1539dc767 +2026-07-28-continuable-subagent-conversations.md: ec194314d88958becde4672a08570fd1facacb3c +2026-07-28-continuable-subagent-conversations.zh.md: 7c776a4e34e16c1cfd56c8964f25b4f4176001dd diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index e0119975d5..ec194314d8 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -44,7 +44,7 @@ Cold resume does not dispatch through a subagent provider. The continuation mana `SubagentProvider.start()` and `SubagentRun` remain exclusively on the unchanged one-shot path. A continuable Activation directly owns its `AgentHandle` and never creates, wraps, or retains a `SubagentRun`; `SubagentRun.steer?()` is therefore absent. -`ctx.subagents.followup(parent, childId, content, { source, signal })` remains the sole continuation-message operation. The exact live parent Agent authorizes delivery; `source` remains durable provenance and grants no authority. The model-facing `send_message` tool keeps only its stable `subagent_id` and `message` fields and always submits a follow-up turn. Both start and follow-up return the accepted `MessageId`, and neither reports how the manager materialized the Activation. +`ctx.subagents.followup(parent, childId, content, { source, signal })` remains the sole continuation-message operation. The exact live parent Agent authorizes delivery; cold resume checks that authority before reconstruction and every path checks it again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize delivery. `source` remains durable provenance and grants no authority. The model-facing `send_message` tool keeps only its stable `subagent_id` and `message` fields and always submits a follow-up turn. Both start and follow-up return the accepted `MessageId`, and neither reports how the manager materialized the Activation. For start and follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance. After the operation returns its `MessageId`, the manager owns the Activation independently; later caller cancellation does not cancel the accepted turn or dispose the child. @@ -107,7 +107,7 @@ Child release occurs only after the child Agent is quiescent, every child of tha Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. -Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Every materialized start and live delivery rechecks caller cancellation, draining, and Activation disposal in the same synchronous span as inbox submission, so teardown that begins before acceptance prevents delivery to the closing handle. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain. +Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission. The manager then awaits every materialization that already passed admission until it either installs a resident Activation or completes rollback, snapshots the stable live forest, disposes it child-first, and awaits all `AgentHandle.dispose()` calls. Every materialized start and live delivery rechecks caller cancellation, draining, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain. The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. The manager snapshots the live roots after closing admission, stops its outward lifecycle notifications before cancellation, and retains its internal ownership bookkeeping until every handle settles. Each Activation has one memoized disposal promise so host shutdown, manager unload, child release, and normal settlement can converge without double release. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining handles, and the aggregate drain reports failure after all branches settle. Durable child Sessions survive this process-local teardown. @@ -127,7 +127,7 @@ A later host UI may expose separate **Steer** and **Follow up** actions. Host st Authority is supplied by an exact live Agent tool context. `MessageSource` and `senderSessionId` are durable provenance after admission, not caller-controlled authority. -This version authorizes only the durable child's direct parent. The manager checks `SessionHeader.parentSession` against the exact live parent Agent before registering the child in that parent's `ownedChildren`. Other Agents, ancestors, hosts, teams, and workflows remain rejected until a concrete consumer justifies another authority protocol. +This version authorizes only the durable child's direct parent. The manager checks `SessionHeader.parentSession` against the exact live parent Agent at the final no-await inbox-admission boundary before registering the child in that parent's `ownedChildren`; cold resume also performs an earlier check before reconstruction for fail-fast rejection. Other Agents, ancestors, hosts, teams, and workflows remain rejected until a concrete consumer justifies another authority protocol. Parent-originated delivery requires the parent to be live when admitted and keeps it live through the ownership relationship. @@ -185,7 +185,7 @@ The implementation pins these behaviors: - Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership through a closing transaction visible to concurrent delivery and drain; lifecycle publication failure emits no unmatched terminal edge. - Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through or requires the initial subagent provider; the persisted provider name remains lifecycle provenance after provider removal, while `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. - A continuable Activation directly owns `AgentHandle` and never creates, wraps, or retains `SubagentRun`; `SubagentProvider.start()` and `SubagentRun` remain one-shot-only, without `SubagentRun.steer?()`. -- `followup()` accepts only the exact live direct parent; durable message provenance cannot authorize delivery. +- `followup()` accepts only the exact live direct parent and rechecks that identity at the final no-await inbox-admission boundary after any materialization; durable message provenance cannot authorize delivery. - Continuation messages always use `Agent.followup()` and share its inbox FIFO, including when the child already has an open turn. - `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `MessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result. - This version exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host and manager teardown retains child-first global cleanup. @@ -194,11 +194,11 @@ The implementation pins these behaviors: - A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation. - Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph. - Final Activation settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` and rejection report `DURABILITY_FAILED`, still dispose the child handle, and still release parent ownership so durability failure cannot leak a `waiting` Activation. -- Host and manager teardown synchronously enter draining, reject new materialization and delivery, stop manager-owned outward notifications, dispose every snapshotted live Activation forest child-first, await every branch despite individual failures, and only then dispose top-level Agents and the manager scope; a private activation-owner scope preserves this order against Cordis effect unwinding, and one memoized disposal promise per Activation makes concurrent normal settlement idempotent. +- Host and manager teardown synchronously enter draining, reject new materialization and delivery, await every admitted materialization through publication or rollback, stop manager-owned outward notifications, dispose the stable live Activation forest child-first, await every branch despite individual failures, and only then dispose top-level Agents and the manager scope; a private activation-owner scope preserves this order against Cordis effect unwinding, and one memoized disposal promise per Activation makes concurrent normal settlement idempotent. - This version exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. - Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. - No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. -- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, provider-independent cold resume, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. +- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, drain quiescence for a materialization caught between Agent publication and Activation registration, provider-independent cold resume, final exact-parent reauthorization after cold-resume materialization, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. - Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools. - A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index fdf34d6826..7c776a4e34 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -44,7 +44,7 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的 `SubagentProvider.start()` 和 `SubagentRun` 只保留在不变的 one-shot 路径上。可继续激活直接持有自身的 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;因此,`SubagentRun.steer?()` 不存在。 -`ctx.subagents.followup(parent, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。确切的在线 parent Agent 授权投递;`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id` 和 `message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `MessageId`,两者都不报告管理器如何物化激活。 +`ctx.subagents.followup(parent, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。确切的在线 parent Agent 授权投递;冷恢复会在重建前检查该权限,每条路径还会在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id` 和 `message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `MessageId`,两者都不报告管理器如何物化激活。 对于 start 和 follow-up,调用方 signal 只在 inbox 接受消息前持有查找、物化和准入。操作返回 `MessageId` 后,管理器会独立持有该激活;调用方之后的取消不会取消已接受的轮次,也不会 dispose child。 @@ -107,7 +107,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 -顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、draining 和激活 dispose,因此在接受前开始的拆卸会阻止向正在关闭的 handle 投递。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。 +顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入。管理器随后等待每个已经通过准入的物化过程,直至它安装驻留激活或完成回滚,再对稳定的在线森林创建快照,按 child-first 顺序 dispose,并等待全部 `AgentHandle.dispose()` 调用。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、draining、激活 dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。 activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。管理器在关闭准入后对在线根节点创建快照,在取消前停止自身的对外生命周期通知,并保留内部所有权簿记,直至每个 handle 都结算。每次激活有一个记忆化的 dispose promise,使宿主关闭、管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余 handle,聚合 drain 则在所有分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。 @@ -127,7 +127,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 权限来自确切的在线 Agent 工具上下文。`MessageSource` 和 `senderSessionId` 是准入后的持久化来源信息,不是由调用方控制的权限。 -本版本只授权持久化 child 的直接 parent。管理器会根据确切的在线 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、宿主、团队和工作流仍被拒绝,直至有具体消费方证明另一种权限协议合理。 +本版本只授权持久化 child 的直接 parent。管理器会在将 child 注册到该 parent 的 `ownedChildren` 之前,于最终无 await 的 inbox 准入边界根据确切的在线 parent Agent 检查 `SessionHeader.parentSession`;冷恢复还会在重建前执行一次更早的检查,以便快速失败。其他 Agent、祖先、宿主、团队和工作流仍被拒绝,直至有具体消费方证明另一种权限协议合理。 由 parent 发起的投递要求 parent 在准入时在线,并通过所有权关系使其继续在线。 @@ -185,7 +185,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并通过一个对并发投递和 drain 可见的关闭事务回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系;生命周期发布失败不会产生无配对的终止事件。 - 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过或依赖初始 subagent 提供方;提供方移除后,持久化的提供方名称仍作为生命周期来源信息保留,且 `SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。 - 可继续激活直接持有 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;`SubagentProvider.start()` 和 `SubagentRun` 只用于 one-shot,且没有 `SubagentRun.steer?()`。 -- `followup()` 只接受确切的在线直接 parent;持久化消息来源信息不能授权投递。 +- `followup()` 只接受确切的在线直接 parent,并在任何物化之后的最终无 await 的 inbox 准入边界再次检查该身份;持久化消息来源信息不能授权投递。 - 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括 child 已有开放轮次的情况。 - `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 - 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 @@ -194,11 +194,11 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。 - 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。 - 激活最终结算时,只有 `ctx.sessions.flush(child.session) === true` 才确认持久性;`false` 和 rejection 会报告 `DURABILITY_FAILED`,但仍会 dispose child handle 并释放 parent 所有权,使持久性失败不会泄漏 `waiting` 激活。 -- 宿主和管理器拆卸会同步进入 draining,拒绝新的物化和投递,停止由管理器负责的对外通知,按 child-first 顺序 dispose 处于快照中的整个在线激活森林,即使个别分支失败也会等待所有分支,之后才 dispose 顶层 Agent 和管理器作用域;私有 activation-owner 作用域会确保 Cordis effect 的逆序撤销不破坏该顺序,每次激活使用一个记忆化的 dispose promise,使并发的正常结算保持幂等。 +- 宿主和管理器拆卸会同步进入 draining,拒绝新的物化和投递,等待每个已获准的物化过程完成发布或回滚,停止由管理器负责的对外通知,按 child-first 顺序 dispose 稳定的在线激活森林,即使个别分支失败也会等待所有分支,之后才 dispose 顶层 Agent 和管理器作用域;私有 activation-owner 作用域会确保 Cordis effect 的逆序撤销不破坏该顺序,每次激活使用一个记忆化的 dispose promise,使并发的正常结算保持幂等。 - 本版本不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 - 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 - 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 -- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、不依赖提供方的冷恢复、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。 +- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、drain 会等待夹在 Agent 发布与 Activation 注册之间的物化过程完全停稳、不依赖提供方的冷恢复、冷恢复物化后的最终确切 parent 再授权、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。 - 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 - 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 39a4ce1dc3..1a50c35c41 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -120,7 +120,8 @@ export function apply(ctx: Context, config: Config): void { const detached = createDetachedRuns() // Only the start edge guarantees registry access. Retain each local child // through its paired end so stop hooks keep the session workspace after the - // handle unregisters the agent. + // handle unregisters the agent. Every retained entry relies on that paired + // end; a producer that can omit it must provide another release edge. const subagentChildren = new Map() ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs') diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index b8ed113445..0a3a116d38 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 0e59a1ad5f256de4d6505d3d00d3790d7738a457 -README.zh.md: 073b4903520544e1b5b9209f792aa5e05d9334b0 +README.md: 06047ac87e84d50d8dc1a965c7d2499cbe58076d +README.zh.md: 206a7d6e95f61ab152829e614cfaf1d15c5bec33 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 0e59a1ad5f..06047ac87e 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -31,11 +31,11 @@ Multiple providers may coexist under different names. This lets a deployment exp | `start(name, request)` | Validate an ordinary caller request, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. | | `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. | | `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. | -| `drainContinuable()` | Close continuable admission synchronously, then dispose every live Activation forest child-first. A host calls this before disposing top-level agents so no descendant outlives the runtime that owns its teardown. An aggregate error surfaces after every branch settles when any failed. | +| `drainContinuable()` | Close continuable admission synchronously, await every materialization that already passed admission through publication or rollback, then dispose the stable live Activation forest child-first. A host calls this before disposing top-level agents so no descendant outlives the runtime that owns its teardown. An aggregate error surfaces after every branch settles when any failed. | `SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child. -Follow-up authority comes from the exact live direct parent recorded in the child's durable header. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority. +Follow-up authority comes from the exact live direct parent recorded in the child's durable header. Cold resume checks that authority before reconstruction and again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize delivery. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority. Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 073b490352..206a7d6e95 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -31,11 +31,11 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 | | `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | | `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | -| `drainContinuable()` | 同步关闭可继续准入,然后以子先于父的顺序 dispose 每一个实时 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 | +| `drainContinuable()` | 同步关闭可继续准入,等待每个已经通过准入的物化过程完成发布或回滚,然后按 child-first 顺序 dispose 稳定的在线 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 | `SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。 -后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。 +后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。冷恢复会在重建前检查该权限,并在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。 同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 109c5b666f..2a4fdefc24 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -156,11 +156,22 @@ interface Activation { poke: PromiseWithResolvers } +/** Inputs shared by fresh and resumed Activation materialization. */ +interface MaterializeInputs { + childId: SessionId + provider: string + parent: Agent + /** Creation inputs; absent for a cold resume, which loads the persisted session. */ + create?: { seed: readonly SessionEvent[]; meta: NonNullable } + agentOptions: AgentOptions + composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } + signal: AbortSignal +} + /** * Read one Activation's current disposal transaction. This indirection exists - * because a mutable field read inside a long-lived closure narrows to its - * last-seen value, which would flatten these genuine runtime checks to - * constants. + * because TypeScript would otherwise narrow repeated reads of the mutable field + * inside a long-lived closure to constants instead of re-reading runtime state. * @param activation - the Activation to inspect. * @returns the in-flight or settled disposal, or `undefined` while resident. */ @@ -206,6 +217,8 @@ class ChildLock { export class SubagentContinuationManager { /** Child session id → its live Activation. Process-local, never durable. */ private activations = new Map() + /** Materializations admitted before drain, tracked through publication or rollback. */ + private readonly materializations = new Set>() private readonly locks = new ChildLock() /** Structural Cordis owner of every Activation handle. */ private readonly ownerCtx: Context @@ -332,7 +345,6 @@ export class SubagentContinuationManager { if (activation.disposal !== undefined) { return activation.disposal.then(() => undefined, () => undefined) } - this.authorizeLive(parent, activation) return this.submitAdmitted(activation, content, options.source, parent, options.signal) }) /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that @@ -345,17 +357,20 @@ export class SubagentContinuationManager { } /** - * Dispose every live Activation forest child-first and await all handles. - * Sibling branches drain independently: one failure is recorded but never - * prevents the remaining handles from being attempted, and the aggregate - * rejects only after every branch settles. - * @returns once every snapshotted Activation released its handle. + * Close admission, await every already-admitted materialization through + * publication or rollback, then dispose the stable live Activation forest + * child-first. Sibling branches drain independently: one failure is recorded + * but never prevents the remaining handles from being attempted, and the + * aggregate rejects only after every branch settles. + * @returns once materialization is quiescent and every live Activation released its handle. * @throws an aggregate error when any branch failed to release. */ async drain(): Promise { - // Close admission synchronously before the first await, so no new creation, - // cold resume, or delivery can race the snapshot below. + // Close admission synchronously before the first await. Materializations + // already past that cutoff remain tracked until their handle is installed + // or rollback completes, producing a stable forest for the later snapshot. this.draining = true + await Promise.all([...this.materializations]) // Snapshot roots after closing admission: a root is an Activation no live // Activation owns, so disposing roots recurses child-first into the forest. const owned = new Set() @@ -489,16 +504,22 @@ export class SubagentContinuationManager { * a continuation-managed parent. Rejection leaves no Activation, no handle, * and no ownership membership. */ - private async materialize(inputs: { - childId: SessionId - provider: string - parent: Agent - /** Creation inputs; absent for a cold resume, which loads the persisted session. */ - create?: { seed: readonly SessionEvent[]; meta: NonNullable } - agentOptions: AgentOptions - composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } - signal: AbortSignal - }): Promise { + private materialize(inputs: MaterializeInputs): Promise { + this.assertAdmitting() + const settled = Promise.withResolvers() + this.materializations.add(settled.promise) + return this.materializeTracked(inputs).finally(() => { + this.materializations.delete(settled.promise) + settled.resolve() + }) + } + + /** + * Perform one tracked materialization. The caller keeps the drain barrier + * registered until this either returns a resident Activation or finishes + * rollback. + */ + private async materializeTracked(inputs: MaterializeInputs): Promise { const { childId, provider, parent } = inputs // No id pre-check here: the child lock serializes each durable child, both // callers reach this only after confirming no Activation exists, and @@ -673,19 +694,12 @@ export class SubagentContinuationManager { 'ACTIVATION_CLOSING', ) } - return this.submit(activation, content, source, parent) - } - - /** - * Authorize delivery to a live Activation. A parent must be the exact live - * direct parent recorded in the child's durable header. - */ - private authorizeLive(parent: Agent, activation: Activation): void { this.authorizeLineage( parent, activation.childId, activation.handle.agent.session.header.parentSession, ) + return this.submit(activation, content, source, parent) } /** diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 4206ce299d..79a1376b54 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -691,6 +691,29 @@ describe('continuable durability and teardown', () => { expect(ctx.agents.list()).toEqual([parent]) }) + it('waits for a published materialization to finish rollback before drain resolves', async () => { + const { ctx, parent } = await setup([]) + const order: string[] = [] + const drains: Promise[] = [] + ctx.on('agent/created', (child) => { + if (child === parent) return + const draining = ctx.subagents.drainContinuable().then(() => { order.push('drain') }) + drains.push(draining) + }) + ctx.on('agent/disposed', (child) => { + if (child !== parent) order.push('disposed') + }) + + // `agent/created` runs after registry publication but before materialize() + // receives the handle and installs the Activation. + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toMatchObject({ code: 'DRAINING' }) + await Promise.all(drains) + + expect(order).toEqual(['disposed', 'drain']) + expect(ctx.agents.list()).toEqual([parent]) + }) + it('admits a live follow-up before a later drain can begin disposal', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) @@ -739,6 +762,80 @@ describe('continuable durability and teardown', () => { }) describe('continuable review regressions', () => { + it('rechecks exact parent liveness after cold-resume materialization', async () => { + const { ctx } = await setup([textResponse('first')]) + const parentId = SessionId('replaceable-parent') + const originalParent = await ctx.agents.create({ + sessionId: parentId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const started = await ctx.subagents.startContinuable(startSpec(originalParent.agent)) + await waitNoActivation(ctx, started.childId) + + const manager = (ctx.subagents as unknown as { + continuations: { ownerCtx: Context } + }).continuations + const ownerAgents = manager.ownerCtx.agents + const originalResume = ownerAgents.resume.bind(ownerAgents) + const resumed = Promise.withResolvers() + const releaseResume = Promise.withResolvers() + const resumeSpy = vi.spyOn(ownerAgents, 'resume').mockImplementation(async (options) => { + const handle = await originalResume(options) + resumed.resolve(undefined) + await releaseResume.promise + return handle + }) + + const delivery = followup( + ctx, + originalParent.agent, + started.childId, + message('must not cross parent replacement'), + ) + await resumed.promise + await originalParent.dispose() + const replacement = await ctx.agents.create({ + sessionId: parentId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + releaseResume.resolve(undefined) + + await expect(delivery).rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + resumeSpy.mockRestore() + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'must not cross parent replacement')).toBe(false) + await replacement.dispose() + }) + + it('clears the accepted reservation when Agent.followup throws', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const manager = (ctx.subagents as unknown as { + continuations: { + activations: Map }> + } + }).continuations + const activation = manager.activations.get(started.childId)! + const realFollowup = child.followup.bind(child) + child.followup = () => { + throw new Error('synthetic inbox failure') + } + + await expect(followup(ctx, parent, started.childId, message('throws'))) + .rejects.toThrow(/synthetic inbox failure/) + expect(activation.accepted.size).toBe(0) + + child.followup = realFollowup + const drained = ctx.subagents.drainContinuable() + hold.resolve(undefined) + await drained + }) + it('reports the child\'s own terminal reason, not teardown success', async () => { // The child hits its token ceiling; teardown still succeeds. const { ctx, parent } = await setupWith(new MockAdapter([ From 191c8cd64000658c3134776132225c71566eb814 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 31 Jul 2026 13:51:46 +0800 Subject: [PATCH 070/114] fix(acp): scope connection-owned continuation drain --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 12 +- ...8-continuable-subagent-conversations.zh.md | 12 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 12 + docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- packages/acp/acp/README.i18n.yaml | 4 +- packages/acp/acp/README.md | 2 +- packages/acp/acp/README.zh.md | 2 +- packages/acp/acp/src/index.ts | 16 +- packages/acp/acp/tests/dispose.spec.ts | 11 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 + packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 3 +- packages/subagent/subagent/README.zh.md | 3 +- .../subagent/subagent/src/continuation.ts | 326 +++++++++++++----- packages/subagent/subagent/src/index.ts | 17 + .../subagent/tests/continuation.spec.ts | 193 +++++++++++ .../subagent/subagent/tests/service.spec.ts | 3 +- 22 files changed, 522 insertions(+), 118 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 816700d7e7..87101c479a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.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-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: ec194314d88958becde4672a08570fd1facacb3c -2026-07-28-continuable-subagent-conversations.zh.md: 7c776a4e34e16c1cfd56c8964f25b4f4176001dd +2026-07-28-continuable-subagent-conversations.md: 8e867254a726c57200936cff6c83831abb4c66ad +2026-07-28-continuable-subagent-conversations.zh.md: 3383a0b8fd7b17ad40c3a03f32a78df058afb120 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index ec194314d8..8e867254a7 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -107,9 +107,9 @@ Child release occurs only after the child Agent is quiescent, every child of tha Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. -Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission. The manager then awaits every materialization that already passed admission until it either installs a resident Activation or completes rollback, snapshots the stable live forest, disposes it child-first, and awaits all `AgentHandle.dispose()` calls. Every materialized start and live delivery rechecks caller cancellation, draining, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain. +Top-level teardown is host-owned rather than represented as another Activation. Manager unload uses `drainContinuable()` to close manager-wide admission synchronously, await every admitted materialization through publication or rollback, stop the stable live forest, and release it child-first. A host that owns selected top-level Agents instead uses `drainContinuableDescendants(parents)`: exact Agent identities close admission only below those roots until each leaves the registry, while unrelated forests and manager-wide admission remain live; the manager stops their visible descendants before its first await, waits only materializations admitted below those roots, and releases only the selected branches. Every materialized start and live delivery rechecks caller cancellation, the applicable draining scope, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after the applicable drain settles may the host dispose its top-level Agents; only the global drain precedes manager-scope disposal. -The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. The manager snapshots the live roots after closing admission, stops its outward lifecycle notifications before cancellation, and retains its internal ownership bookkeeping until every handle settles. Each Activation has one memoized disposal promise so host shutdown, manager unload, child release, and normal settlement can converge without double release. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining handles, and the aggregate drain reports failure after all branches settle. Durable child Sessions survive this process-local teardown. +The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. Each materialization registers its barrier participant and snapshots its exact live ancestry before starting the inner transaction, then remains tracked until it installs an Activation or fully rolls back. The Activation retains weak membership of that ancestry, so an intermediate Agent may leave the registry without hiding a still-live descendant from its host root. Each Activation installs one memoized disposal promise before cancellation or recursive callbacks, allowing scoped host shutdown, global manager unload, child release, and normal settlement to converge without double release. Cancellation propagates top-down before slow descendant cleanup; handle release remains child-first. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining selected handles, and the aggregate drain reports failure after all selected branches settle. Durable child Sessions survive this process-local teardown. ### Deferred report delivery @@ -135,7 +135,7 @@ Parent-originated delivery requires the parent to be live when admitted and keep Without Tasks there is no `task_output`, `task_kill`, Task status, per-message result promise, or public subagent cancellation operation. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, the parent cannot cancel the message, turn, or Activation through `ctx.subagents`; `Agent.cancel()` remains a lower-level Agent capability that this version does not expose through the subagent service. -Host and manager teardown remains the lifecycle-wide stop path. It closes admission, disposes every live Activation forest child-first, and preserves the durable Sessions. +Host and manager teardown remains the lifecycle stop path. Manager unload applies it globally; a host applies it only below the exact top-level Agents it owns. Each form closes the applicable admission scope, stops the selected visible Activations, awaits admitted materializations in that scope, releases child-first, and preserves the durable Sessions. Each turn requests the Session durability checkpoint, and final Activation settlement requires the manager to inspect `ctx.sessions.flush()` rather than ignore its boolean result. `true` confirms that at least one durability listener participated and every listener settled successfully. `false` or rejection reports `DURABILITY_FAILED`; normal background settlement logs the lifecycle failure, while an explicit host or manager drain includes it in the aggregate rejection after all branches settle. Either way, the manager still disposes the handle and releases ownership, and the persisted child state may be missing or stale on a later resume. @@ -188,17 +188,17 @@ The implementation pins these behaviors: - `followup()` accepts only the exact live direct parent and rechecks that identity at the final no-await inbox-admission boundary after any materialization; durable message provenance cannot authorize delivery. - Continuation messages always use `Agent.followup()` and share its inbox FIFO, including when the child already has an open turn. - `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `MessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result. -- This version exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host and manager teardown retains child-first global cleanup. +- This version exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host-scoped and manager-global teardown retain child-first cleanup. - This version exposes no subagent steering operation or current-turn controller state. - An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained. - A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation. - Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph. - Final Activation settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` and rejection report `DURABILITY_FAILED`, still dispose the child handle, and still release parent ownership so durability failure cannot leak a `waiting` Activation. -- Host and manager teardown synchronously enter draining, reject new materialization and delivery, await every admitted materialization through publication or rollback, stop manager-owned outward notifications, dispose the stable live Activation forest child-first, await every branch despite individual failures, and only then dispose top-level Agents and the manager scope; a private activation-owner scope preserves this order against Cordis effect unwinding, and one memoized disposal promise per Activation makes concurrent normal settlement idempotent. +- Manager teardown closes admission globally; a host owning selected top-level Agents instead closes admission only below their exact identities until those roots leave the registry. Both track admitted materializations by exact ancestry, install one memoized disposal cutoff per selected visible Activation, propagate cancellation top-down, release handles child-first, await every selected branch despite individual failures, and only then dispose the corresponding top-level Agents or manager scope. - This version exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. - Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. - No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. -- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, drain quiescence for a materialization caught between Agent publication and Activation registration, provider-independent cold resume, final exact-parent reauthorization after cold-resume materialization, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. +- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, global and parent-scoped drain quiescence for materialization caught between Agent publication and Activation registration, sibling-forest isolation, exact ancestry after an intermediate Agent leaves the registry, provider-independent cold resume, final exact-parent reauthorization after cold-resume materialization, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. - Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools. - A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 7c776a4e34..3383a0b8fd 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -107,9 +107,9 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 -顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入。管理器随后等待每个已经通过准入的物化过程,直至它安装驻留激活或完成回滚,再对稳定的在线森林创建快照,按 child-first 顺序 dispose,并等待全部 `AgentHandle.dispose()` 调用。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、draining、激活 dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。 +顶层拆卸由宿主负责,而不表示为另一次激活。管理器卸载使用 `drainContinuable()` 同步关闭管理器全局准入,等待每个已获准的物化过程完成发布或回滚,停止稳定的在线森林,并按 child-first 顺序释放。拥有选定顶层 Agent 的宿主则使用 `drainContinuableDescendants(parents)`:确切的 Agent 身份只关闭这些根之下的准入,直到每个身份离开注册表,而无关森林和管理器全局准入保持在线;管理器会在第一次 await 之前停止其可见后代,只等待这些根之下已获准的物化过程,并且只释放选定分支。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、适用的 draining 作用域、Activation dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有适用的 drain 结算后,宿主才能 dispose 自己的顶层 Agent;只有全局 drain 会先于管理器作用域 dispose。 -activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。管理器在关闭准入后对在线根节点创建快照,在取消前停止自身的对外生命周期通知,并保留内部所有权簿记,直至每个 handle 都结算。每次激活有一个记忆化的 dispose promise,使宿主关闭、管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余 handle,聚合 drain 则在所有分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。 +activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。每个物化过程都会在启动内部事务前注册其屏障参与项,并对其确切的在线祖先建立快照,然后保持跟踪,直到安装 Activation 或完全回滚。Activation 会以弱引用方式记录其属于这组祖先,因此中间 Agent 即使离开注册表,也不会让仍在线的后代脱离宿主根节点的可见范围。每个 Activation 都会在取消或递归回调前安装一个记忆化的 dispose promise,使限定作用域的宿主关闭、全局管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。取消会在等待缓慢的后代清理之前自顶向下传播;handle 释放仍是 child-first。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余选中 handle,聚合 drain 则在所有选中分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。 ### 延后的报告投递 @@ -135,7 +135,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本版本不通过 subagent 服务暴露它。 -宿主和管理器拆卸仍是覆盖整个生命周期的停止路径。它会关闭准入,按 child-first 顺序 dispose 每个在线激活森林,并保留持久化会话。 +宿主和管理器拆卸仍是生命周期停止路径。管理器卸载会全局应用它;宿主只会在自己确切拥有的顶层 Agent 之下应用它。两种形式都会关闭适用的准入作用域,停止选中的可见 Activation,等待该作用域中已获准的物化过程,按 child-first 顺序释放,并保留持久化 Session。 每个轮次都会请求执行会话持久性检查点,激活最终结算时,管理器必须检查 `ctx.sessions.flush()`,而不能忽略其布尔结果。`true` 确认至少有一个持久性 listener 参与,且所有 listener 都成功结算。`false` 或 rejection 会报告 `DURABILITY_FAILED`;普通后台结算会记录该生命周期失败,显式的宿主或管理器 drain 则会在所有分支结算后,将其纳入聚合 rejection。无论结果如何,管理器仍会 dispose handle 并释放所有权,后续恢复时持久化 child 状态可能缺失或陈旧。 @@ -188,17 +188,17 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - `followup()` 只接受确切的在线直接 parent,并在任何物化之后的最终无 await 的 inbox 准入边界再次检查该身份;持久化消息来源信息不能授权投递。 - 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括 child 已有开放轮次的情况。 - `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 -- 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 +- 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,限定到宿主的拆卸与管理器全局拆卸则保留 child-first 清理。 - 本版本不暴露 subagent steering 操作或当前轮次控制方状态。 - 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。 - 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。 - 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。 - 激活最终结算时,只有 `ctx.sessions.flush(child.session) === true` 才确认持久性;`false` 和 rejection 会报告 `DURABILITY_FAILED`,但仍会 dispose child handle 并释放 parent 所有权,使持久性失败不会泄漏 `waiting` 激活。 -- 宿主和管理器拆卸会同步进入 draining,拒绝新的物化和投递,等待每个已获准的物化过程完成发布或回滚,停止由管理器负责的对外通知,按 child-first 顺序 dispose 稳定的在线激活森林,即使个别分支失败也会等待所有分支,之后才 dispose 顶层 Agent 和管理器作用域;私有 activation-owner 作用域会确保 Cordis effect 的逆序撤销不破坏该顺序,每次激活使用一个记忆化的 dispose promise,使并发的正常结算保持幂等。 +- 管理器拆卸会全局关闭准入;拥有选定顶层 Agent 的宿主则只关闭这些确切身份之下的准入,直到这些根离开注册表。两者都会按确切祖先关系跟踪已获准的物化过程,为每个选中的可见 Activation 安装一个记忆化 dispose 截止点,自顶向下传播取消,按 child-first 顺序释放 handle,即使个别分支失败也会等待所有选中分支,之后才 dispose 对应的顶层 Agent 或管理器作用域。 - 本版本不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 - 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 - 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 -- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、drain 会等待夹在 Agent 发布与 Activation 注册之间的物化过程完全停稳、不依赖提供方的冷恢复、冷恢复物化后的最终确切 parent 再授权、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。 +- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、全局和限定到 parent 作用域的 drain 都会等待夹在 Agent 发布与 Activation 注册之间的物化过程完全停稳、同级森林隔离、中间 Agent 离开注册表后的确切祖先关系、不依赖提供方的冷恢复、冷恢复物化后的最终确切 parent 再授权、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。 - 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 - 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 420b0f4b66..9a1437ff5f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/acp/acp/src/index.ts:67`](../packages/acp/acp/src/index.ts) +Source: [`packages/acp/acp/src/index.ts:70`](../packages/acp/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index fd51125a06..d574b8b1dd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1989,6 +1989,18 @@ async followup( parent: Agent, childId: SessionId, content: ContentBlock[], opti */ async drainContinuable(): Promise +/** + * Close continuable admission below exact live parent Agents, stop only their + * visible descendant Activations synchronously, then await admitted scoped + * materializations and release those forests child-first. The scoped cutoff + * lasts until each exact parent leaves the registry; unrelated parent trees + * remain live. + * @param parents - exact host-owned parent Agents entering teardown. + * @returns once every retained descendant Activation released its `AgentHandle`. + * @throws an aggregate error after all scoped branches settle when any failed. + */ +async drainContinuableDescendants(parents: readonly Agent[]): Promise + /** * Register a provider under its name. Registration is effect-scoped and HMR * safe; removing a provider blocks new starts but does not revoke runs that diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 39393ba161..1e0a7546d3 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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/subagent.md -subagent.md: e160c596acb55f0e94cba84b8c79355c966eb51a -subagent.zh.md: 6b934a523fa0ea5d53ea9a670e56b72b7f785593 +subagent.md: eff78bae6fcf7440cce66f122771ff623a5eb3c7 +subagent.zh.md: a898f438c257db05a3d84e6318ce23e1f366e193 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index e160c596ac..eff78bae6f 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -129,7 +129,7 @@ For both operations the caller signal owns lookup, materialization, and admissio Every Activation owns its `AgentHandle` and an `ownedChildren: Set`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens only after the child Agent is quiescent, every child of that child is disposed, the final durability checkpoint settles, and the child's `AgentHandle` completes disposal. -Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. `drainContinuable()` is the lifecycle-wide stop path: it closes admission synchronously, then disposes every live Activation forest child-first, awaiting every branch despite individual failures. Durable child Sessions survive that process-local teardown. +Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. `drainContinuable()` closes manager-wide admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown. ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 6b934a523f..a898f438c2 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -129,7 +129,7 @@ Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 ` 每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、最终的持久性检查点结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。 -只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。`drainContinuable()` 是覆盖整个生命周期的停止路径:它同步关闭准入,随后以子级优先的方式 dispose 每一片存活的 Activation 森林,尽管个别分支失败仍会等待每个分支。持久化子会话不受该进程内拆卸的影响。 +只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。`drainContinuable()` 会关闭管理器全局准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。 ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7cccf7ab63..c54b36368d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,7 +10,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:327`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index d359613cd6..7c8be3dc18 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/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/acp/acp/README.md -README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88 -README.zh.md: c1e7d045b55119b62ad44d81071188e1ed6110d5 +README.md: 9a48fdec3330cd364c1ab6de4c117b20af0f443f +README.zh.md: 65732f41277a8760bfd2824aea12b0f240ae8025 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 1b188b994d..9a48fdec33 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -35,7 +35,7 @@ Committed-message output intentionally trades token-by-token latency for a clean ## Lifecycle -Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then disposes all owned agent handles in parallel and awaits their loop/session cleanup. An ACP-only plugin reload therefore leaves no orphan agent. +Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting their loop/session cleanup. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent. ## Running diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index c1e7d045b5..65732f4127 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -35,7 +35,7 @@ ## 生命周期 -客户端断开连接与 Cordis 的 dispose(资源释放)共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose,并等待它们的循环/会话清理完成。因此,单独重载 ACP 插件不会遗留孤儿 agent。 +客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待它们的循环/会话清理完成。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。 ## 运行 diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 2c877df7c4..58823ff407 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -49,8 +49,11 @@ export const inject = ['agents'] * shutdown hook; an absent service means nothing continuable was materialized. */ interface ContinuableDrain { - /** Close continuable admission, then dispose every live Activation child-first. */ - drainContinuable(): Promise + /** + * Close admission below exact host-owned parents, then dispose only their + * continuable descendants child-first. + */ + drainContinuableDescendants(parents: readonly Agent[]): Promise } /** Preserve invalid-parameter detail in the SDK wire error message. */ @@ -345,15 +348,16 @@ export function apply(ctx: Context, config: AcpConfig): void { } quiescing = (async () => { // Continuable subagents outlive the turn that started them, and their - // Activations own descendant teardown. Drain that forest child-first - // BEFORE disposing the top-level agents, so no descendant is left holding - // a runtime its owner already released. + // Activations own descendant teardown. Drain only these sessions' forests + // child-first BEFORE disposing the top-level agents, so no descendant is + // left holding a runtime its owner already released and another frontend + // sharing this Context remains live. // Read the one teardown method structurally: the bridge needs no other // part of the subagent seam, so it does not depend on that package. const subagents = ctx.get('subagents') as ContinuableDrain | undefined if (subagents !== undefined) { try { - await subagents.drainContinuable() + await subagents.drainContinuableDescendants(records.map(record => record.agent)) } catch (error: unknown) { logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`) } diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 4be0810513..7638c4bd9e 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { makeBridgeHarness, type BridgeHarness } from './harness.ts' @@ -28,21 +29,25 @@ describe('ACP connection ownership', () => { it('drains continuable subagents before disposing its own sessions', async () => { harness = await makeBridgeHarness() const order: string[] = [] + let drainedParents: readonly Agent[] = [] // A continuable Activation outlives the turn that started it, so the bridge // must release that forest before the agents whose runtime it depends on. harness.ctx.provide('subagents', { - drainContinuable: () => { + drainContinuableDescendants: (parents: readonly Agent[]) => { + drainedParents = parents order.push('drained') return Promise.resolve() }, } as never) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(sessionId))! harness.ctx.on('agent/disposed', () => { order.push('agent disposed') }) await harness.acpFiber.dispose() expect(order).toEqual(['drained', 'agent disposed']) + expect(drainedParents).toEqual([agent]) expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) @@ -51,7 +56,7 @@ describe('ACP connection ownership', () => { const order: string[] = [] const release = Promise.withResolvers() harness.ctx.provide('subagents', { - drainContinuable: async () => { + drainContinuableDescendants: async () => { order.push('drain started') await release.promise order.push('drain finished') @@ -79,7 +84,7 @@ describe('ACP connection ownership', () => { const warnings: string[] = [] harness.ctx.logger.warn = (message: string) => { warnings.push(message) } harness.ctx.provide('subagents', { - drainContinuable: () => Promise.reject(new Error('activation teardown failed')), + drainContinuableDescendants: () => Promise.reject(new Error('activation teardown failed')), } as never) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 59e901b2fe..0990ff339a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -896,6 +896,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async drainContinuable(): Promise', jsDoc: '/**\n * Close continuable admission synchronously, then dispose every live\n * Activation forest child-first. A host calls this before disposing top-level\n * agents so no descendant outlives the runtime that owns its teardown.\n * @returns once every live Activation released its `AgentHandle`.\n * @throws an aggregate error after all branches settle when any failed.\n */', }, + { + signature: 'async drainContinuableDescendants(parents: readonly Agent[]): Promise', + jsDoc: '/**\n * Close continuable admission below exact live parent Agents, stop only their\n * visible descendant Activations synchronously, then await admitted scoped\n * materializations and release those forests child-first. The scoped cutoff\n * lasts until each exact parent leaves the registry; unrelated parent trees\n * remain live.\n * @param parents - exact host-owned parent Agents entering teardown.\n * @returns once every retained descendant Activation released its `AgentHandle`.\n * @throws an aggregate error after all scoped branches settle when any failed.\n */', + }, { signature: 'registerProvider(provider: SubagentProvider): () => void', jsDoc: '/**\n * Register a provider under its name. Registration is effect-scoped and HMR\n * safe; removing a provider blocks new starts but does not revoke runs that\n * were already returned to their holders.\n * @param provider - the trusted provider implementation.\n * @returns the exact Cordis effect disposer.\n */', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 0a3a116d38..4a0496100e 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 06047ac87e84d50d8dc1a965c7d2499cbe58076d -README.zh.md: 206a7d6e95f61ab152829e614cfaf1d15c5bec33 +README.md: 6fab6859e2c15fdb1ded023642cbc593e0457384 +README.zh.md: 1f59807a545dcb1fafbac3f301746c7217d15f3a diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 06047ac87e..6fab6859e2 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -32,6 +32,7 @@ Multiple providers may coexist under different names. This lets a deployment exp | `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. | | `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. | | `drainContinuable()` | Close continuable admission synchronously, await every materialization that already passed admission through publication or rollback, then dispose the stable live Activation forest child-first. A host calls this before disposing top-level agents so no descendant outlives the runtime that owns its teardown. An aggregate error surfaces after every branch settles when any failed. | +| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. | `SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child. @@ -76,7 +77,7 @@ The manager derives three internal residency conditions from Agent quiescence an The manager reserves the child identity, resolves the durable descriptor, calls `ctx.agents.create()` (or `ctx.agents.resume()` for cold resume) through a private activation-owner scope, installs the returned `AgentHandle` in the Activation, establishes any continuable-parent ownership, and then submits the prompt. Cold resume never dispatches through a provider because the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input. -A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` or rejection reports `DURABILITY_FAILED` and still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting`. +A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Teardown propagates Agent cancellation top-down before awaiting slow descendants, while handle release remains child-first. Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` or rejection reports `DURABILITY_FAILED` and still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting`. ## Lifecycle events diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 206a7d6e95..1f59807a54 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -32,6 +32,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | | `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | | `drainContinuable()` | 同步关闭可继续准入,等待每个已经通过准入的物化过程完成发布或回滚,然后按 child-first 顺序 dispose 稳定的在线 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 | +| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 | `SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。 @@ -76,7 +77,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发,因为持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入。 -受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose(子先于父)。顶层及其他非继续执行的 Agent 没有 Activation,处于该等待图之外。最终结算只把 `ctx.sessions.flush(child.session) === true` 视为持久性确认;`false` 或拒绝会报告 `DURABILITY_FAILED`,但仍会 dispose 句柄并释放所有权,因为保留失败的子 agent 会使其祖先永久停留在 `waiting`。 +受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose(子先于父)。拆卸会先自顶向下传播 Agent 取消,再等待缓慢的后代,而 handle 释放仍保持 child-first。顶层及其他非继续执行的 Agent 没有 Activation,处于该等待图之外。最终结算只把 `ctx.sessions.flush(child.session) === true` 视为持久性确认;`false` 或拒绝会报告 `DURABILITY_FAILED`,但仍会 dispose 句柄并释放所有权,因为保留失败的子 agent 会使其祖先永久停留在 `waiting`。 ## 生命周期事件 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 2a4fdefc24..2bd89607f8 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -131,6 +131,12 @@ interface Activation { readonly provider: string /** The retained live Agent handle, disposed exactly once at settlement. */ readonly handle: AgentHandle + /** + * Exact live Agent ancestry observed when this Activation materialized. + * Weak membership preserves host-scope identity across an intermediate + * ancestor leaving the registry without retaining that ancestor's runtime. + */ + readonly ancestry: WeakSet /** * Session ids of the child Activations this one owns. Because one Session has * at most one live Activation, the id identifies the live child without @@ -168,6 +174,16 @@ interface MaterializeInputs { signal: AbortSignal } +/** + * One admitted materialization and the exact live ancestry observed at its + * synchronous admission boundary. Retaining identities lets a scoped teardown + * keep waiting even if an intermediate Agent leaves the registry meanwhile. + */ +interface Materialization { + readonly lineage: readonly Agent[] + readonly settled: Promise +} + /** * Read one Activation's current disposal transaction. This indirection exists * because TypeScript would otherwise narrow repeated reads of the mutable field @@ -218,10 +234,17 @@ export class SubagentContinuationManager { /** Child session id → its live Activation. Process-local, never durable. */ private activations = new Map() /** Materializations admitted before drain, tracked through publication or rollback. */ - private readonly materializations = new Set>() + private readonly materializations = new Set() private readonly locks = new ChildLock() /** Structural Cordis owner of every Activation handle. */ private readonly ownerCtx: Context + /** + * Exact roots whose host teardown has begun, with the live lineage members + * observed under each root. Entries remain until that exact root leaves the + * Agent registry, closing admission throughout its host's teardown without + * poisoning a later same-id replacement. + */ + private readonly closingScopes = new Map>() private draining = false constructor( @@ -236,6 +259,9 @@ export class SubagentContinuationManager { // child-first ordering. const scope = ctx.plugin(function activationOwner() {}) this.ownerCtx = scope.ctx + ctx.on('agent/disposed', (agent) => { + this.closingScopes.delete(agent) + }) ctx.effect(function* (this: SubagentContinuationManager) { yield scope.dispose yield () => this.drain() @@ -258,10 +284,10 @@ export class SubagentContinuationManager { * @returns the durable child id and the accepted initial prompt's message id. */ async startContinuable(spec: ContinuableStartSpec): Promise { - this.assertAdmitting() - this.requirePersistence() const request = spec.request const parent = request.parent + this.assertAdmitting(parent) + this.requirePersistence() assertSubagentMaxDepth(request.maxDepth) const childId = SessionId(randomUUID()) const childDepth = resolveChildDepth(parent, request.maxDepth) @@ -283,7 +309,7 @@ export class SubagentContinuationManager { signal: spec.signal, }) spec.signal.throwIfAborted() - this.assertAdmitting() + this.assertAdmitting(parent) const lineageSeedLength = prepared.seed?.length ?? 0 const seed = seedDescriptorTurn(childId, prepared.seed, descriptor) @@ -331,7 +357,7 @@ export class SubagentContinuationManager { content: ContentBlock[], options: SubagentFollowupOptions, ): Promise { - this.assertAdmitting() + this.assertAdmitting(parent) while (true) { const live = await this.locks.run(childId, async () => { const activation = this.activations.get(childId) @@ -350,7 +376,7 @@ export class SubagentContinuationManager { /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that * race reaches the retry below, which then cold-resumes a new Activation. */ if (live !== undefined) return live - this.assertAdmitting() + this.assertAdmitting(parent) options.signal.throwIfAborted() /* v8 ignore stop */ } @@ -370,7 +396,7 @@ export class SubagentContinuationManager { // already past that cutoff remain tracked until their handle is installed // or rollback completes, producing a stable forest for the later snapshot. this.draining = true - await Promise.all([...this.materializations]) + await Promise.all([...this.materializations].map(materialization => materialization.settled)) // Snapshot roots after closing admission: a root is an Activation no live // Activation owns, so disposing roots recurses child-first into the forest. const owned = new Set() @@ -396,14 +422,128 @@ export class SubagentContinuationManager { } } - /** Reject new admission once the host or manager began draining. */ - private assertAdmitting(): void { + /** + * Stop only the continuable descendants of exact live host-owned parents. + * Admission stays closed for those parent trees until each exact parent + * leaves the Agent registry; unrelated trees and manager-wide admission stay + * live. + * @param parents - exact live roots whose continuable descendants must stop. + * @returns once every retained descendant Activation released its handle. + * @throws an aggregate error after all scoped branches settle when any failed. + */ + async drainDescendants(parents: readonly Agent[]): Promise { + const roots = new Set(parents.filter(parent => this.ctx.agents.get(parent.id) === parent)) + if (roots.size === 0) return + + // Publish the scoped admission cutoff before the first await. Merge with an + // earlier call for the same exact root so a converging drain cannot forget + // descendants whose release is already in flight. + for (const root of roots) { + this.closingMembers(root).add(root) + } + + const targets: Activation[] = [] + for (const activation of this.activations.values()) { + const lineage = this.liveLineage(activation.handle.agent) + // Strict descendants only: a continuable Agent may itself be a + // host-owned root, and its host remains responsible for that root handle. + const owners = [...roots].filter(root => activation.handle.agent !== root + && activation.ancestry.has(root)) + if (owners.length === 0) continue + targets.push(activation) + for (const owner of owners) { + const members = this.closingMembers(owner) + members.add(activation.handle.agent) + for (const agent of lineage) members.add(agent) + } + } + const materializations = [...this.materializations].filter((materialization) => { + const owners = [...roots].filter(root => materialization.lineage.includes(root)) + for (const owner of owners) { + const members = this.closingMembers(owner) + for (const agent of materialization.lineage) members.add(agent) + } + return owners.length > 0 + }) + + const ownedTargets = new Set() + for (const activation of targets) { + for (const child of activation.ownedChildren) ownedTargets.add(child) + } + const targetRoots = targets.filter(activation => !ownedTargets.has(activation.childId)) + + // Open every selected transaction before the materialization barrier. + // Disposal propagates cancellation top-down in the same synchronous span; + // handle release remains child-first. + for (const activation of targets) { + const disposal = this.dispose(activation) + void disposal.catch(() => undefined) + } + + await Promise.all(materializations.map(materialization => materialization.settled)) + const failures = await Promise.all(targetRoots.map(async (activation) => { + try { + await this.dispose(activation) + return undefined + } catch (error: unknown) { + return error + } + })) + const reasons = failures.filter(failure => failure !== undefined) + if (reasons.length > 0) { + throw new SubagentError( + `continuable subagent teardown failed for ${reasons.length} scoped activation(s): ` + + reasons.map(reason => errorChain(reason)).join('; '), + 'ACTIVATION_TEARDOWN_FAILED', + ) + } + } + + /** Return the retained member set for one exact scoped-teardown root. */ + private closingMembers(root: Agent): Set { + const existing = this.closingScopes.get(root) + if (existing !== undefined) return existing + const members = new Set() + this.closingScopes.set(root, members) + return members + } + + /** + * Return the exact currently resolvable ancestry from `agent` upward. The + * first element is always the supplied identity, even when it is already + * stale; each ancestor after it must be the registry's current exact entry. + */ + private liveLineage(agent: Agent): Agent[] { + const lineage = [agent] + const seen = new Set([agent.id]) + let parentSession = agent.session.header.parentSession + while (parentSession !== undefined) { + const parent = this.ctx.agents.get(parentSession) + if (parent === undefined || seen.has(parent.id)) break + lineage.push(parent) + seen.add(parent.id) + parentSession = parent.session.header.parentSession + } + return lineage + } + + /** Reject new admission once the manager or this exact parent tree began draining. */ + private assertAdmitting(agent: Agent): void { if (this.draining) { throw new SubagentError( 'continuable subagents are draining; the operation was not admitted', 'DRAINING', ) } + const lineage = this.liveLineage(agent) + for (const [root, members] of this.closingScopes) { + if (members.has(agent) || lineage.includes(root)) { + throw new SubagentError( + `continuable subagents below parent "${root.id}" are draining; the operation was not admitted`, + 'DRAINING', + ) + } + } } /** @@ -443,7 +583,7 @@ export class SubagentContinuationManager { } // The persistence seam takes no signal; recheck before any child work. options.signal.throwIfAborted() - this.assertAdmitting() + this.assertAdmitting(parent) // Authorize the persisted header before folding: only the durable child's // exact live direct parent may continue it. this.authorizeLineage(parent, childId, loaded.meta.parentSession) @@ -505,11 +645,16 @@ export class SubagentContinuationManager { * and no ownership membership. */ private materialize(inputs: MaterializeInputs): Promise { - this.assertAdmitting() + this.assertAdmitting(inputs.parent) const settled = Promise.withResolvers() - this.materializations.add(settled.promise) - return this.materializeTracked(inputs).finally(() => { - this.materializations.delete(settled.promise) + const lineage = this.liveLineage(inputs.parent) + const materialization: Materialization = { + lineage, + settled: settled.promise, + } + this.materializations.add(materialization) + return this.materializeTracked(inputs, lineage).finally(() => { + this.materializations.delete(materialization) settled.resolve() }) } @@ -519,7 +664,10 @@ export class SubagentContinuationManager { * registered until this either returns a resident Activation or finishes * rollback. */ - private async materializeTracked(inputs: MaterializeInputs): Promise { + private async materializeTracked( + inputs: MaterializeInputs, + parentLineage: readonly Agent[], + ): Promise { const { childId, provider, parent } = inputs // No id pre-check here: the child lock serializes each durable child, both // callers reach this only after confirming no Activation exists, and @@ -551,6 +699,7 @@ export class SubagentContinuationManager { childId, provider, handle, + ancestry: new WeakSet([handle.agent, ...parentLineage]), ownedChildren: new Set(), observer, disposal: undefined, @@ -562,7 +711,7 @@ export class SubagentContinuationManager { this.activations.set(childId, activation) try { inputs.signal.throwIfAborted() - this.assertAdmitting() + this.assertAdmitting(parent) this.acquireOwnership(parent, childId) // Every accepted id leaves the inbox exactly once, through dequeue or // discard. Clearing it there is what lets `stateOf()` distinguish a truly @@ -685,7 +834,7 @@ export class SubagentContinuationManager { signal: AbortSignal, ): MessageId { signal.throwIfAborted() - this.assertAdmitting() + this.assertAdmitting(parent) /* v8 ignore next 6 -- only a synchronous re-entrant disposer can change * this field between the caller's live check and this no-await boundary. */ if (disposalOf(activation) !== undefined) { @@ -767,83 +916,100 @@ export class SubagentContinuationManager { } /** - * Release one Activation child-first: dispose owned children, checkpoint - * durability, dispose the handle, and release parent ownership. Memoized, so - * host shutdown, manager unload, child release, and normal settlement - * converge on one teardown. + * Stop one Activation immediately, then release it child-first. The memoized + * transaction is installed before cancellation or recursive callbacks, so + * admission and reentrant teardown converge on the same owner. * * A failed final checkpoint is reported but never prevents handle disposal or * ownership release, because retaining a failed child would permanently pin * its ancestors in `waiting`. + * @param activation - the residency epoch to stop and release. + * @returns the one disposal transaction owned by this Activation. */ private dispose(activation: Activation): Promise { - return (activation.disposal ??= (async () => { - // The memoized assignment above already closed admission for this child: - // no caller may send to a handle after its disposal transaction begins. - this.wake(activation) - const { childId } = activation - let failure: Error | undefined - try { - // Child-first: every owned child must complete disposal before this - // handle is released. - const children = [...activation.ownedChildren] - .map(child => this.activations.get(child)) - .filter((child): child is Activation => child !== undefined) - const childFailures = await Promise.all(children.map(async (child) => { - try { - await this.dispose(child) - return undefined - } catch (error: unknown) { - return error - } - })) - const reasons = childFailures.filter(reason => reason !== undefined) - if (reasons.length > 0) { - failure = new SubagentError( - `subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`, - 'ACTIVATION_TEARDOWN_FAILED', - ) + const existing = activation.disposal + if (existing !== undefined) return existing + const completion = Promise.withResolvers() + // Presence is the admission cutoff. Assign it before the async helper starts + // because that helper cancels Agents and may synchronously re-enter callers. + activation.disposal = completion.promise + void this.finishDisposal(activation).then(completion.resolve, completion.reject) + return completion.promise + } + + /** + * Propagate stop synchronously, then finish the child-first release. + * @param activation - the Activation whose disposal transaction is installed. + * @returns once the handle and ownership edge are released. + */ + private async finishDisposal(activation: Activation): Promise { + this.wake(activation) + const { childId } = activation + // Stop top-down before the first await. Slow descendant cleanup may delay + // release, but it cannot let this ancestor continue model or tool work. + activation.handle.agent.cancel({ kind: 'parent' }) + const idle = activation.handle.agent.whenIdle() + const children = [...activation.ownedChildren] + .map(child => this.activations.get(child)) + .filter((child): child is Activation => child !== undefined) + const childDisposals = children.map(child => this.dispose(child)) + + let failure: Error | undefined + try { + // Release remains child-first even though cancellation propagated + // top-down: every owned child completes before this handle is removed. + const childFailures = await Promise.all(childDisposals.map(async (disposal) => { + try { + await disposal + return undefined + } catch (error: unknown) { + return error } - // Quiesce before the checkpoint: a turn still running would keep - // appending events the flush cannot cover, and a slow flush would let - // model and tool work continue for the whole shutdown. - activation.handle.agent.cancel({ kind: 'parent' }) - await activation.handle.agent.whenIdle() - const durability = await this.checkpoint(activation) - failure ??= durability - // Capture the child-dependent edge data while the child is still live: - // handle disposal unregisters it, and consumers read its log and scope. - activation.observer.capture(activation.handle.agent) + })) + const reasons = childFailures.filter(reason => reason !== undefined) + if (reasons.length > 0) { + failure = new SubagentError( + `subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`, + 'ACTIVATION_TEARDOWN_FAILED', + ) + } + // Quiesce before the checkpoint: a turn still running would keep + // appending events the flush cannot cover. + await idle + const durability = await this.checkpoint(activation) + failure ??= durability + // Capture the child-dependent edge data while the child is still live: + // handle disposal unregisters it, and consumers read its log and scope. + activation.observer.capture(activation.handle.agent) + } catch (error: unknown) { + failure ??= new SubagentError( + `subagent "${childId}" activation teardown failed: ${errorChain(error)}`, + 'ACTIVATION_TEARDOWN_FAILED', + { cause: error }, + ) + } finally { + try { + await activation.handle.dispose() } catch (error: unknown) { failure ??= new SubagentError( - `subagent "${childId}" activation teardown failed: ${errorChain(error)}`, + `subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`, 'ACTIVATION_TEARDOWN_FAILED', { cause: error }, ) } finally { - try { - await activation.handle.dispose() - } catch (error: unknown) { - failure ??= new SubagentError( - `subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`, - 'ACTIVATION_TEARDOWN_FAILED', - { cause: error }, - ) - } finally { - // Only now is the Activation gone: keeping the entry until disposal - // settles makes a racing delivery wait for release rather than - // cold-resume into the still-registered agent. - this.activations.delete(childId) - // Release ownership even on failure: a retained failed child would - // pin its ancestors in `waiting` forever. - this.releaseOwnership(childId) - // Emit once the disposal outcome is known, so a rejecting scoped - // cleanup cannot be reported as a successful epoch. - activation.observer.settle(failure) - } + // Only now is the Activation gone: keeping the entry until disposal + // settles makes a racing delivery wait for release rather than + // cold-resume into the still-registered agent. + this.activations.delete(childId) + // Release ownership even on failure: a retained failed child would pin + // its ancestors in `waiting` forever. + this.releaseOwnership(childId) + // Emit once the disposal outcome is known, so a rejecting scoped cleanup + // cannot be reported as a successful epoch. + activation.observer.settle(failure) } - if (failure !== undefined) throw failure - })()) + } + if (failure !== undefined) throw failure } /** diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index cf07d4aff1..81d88f9d29 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -215,6 +215,23 @@ export class SubagentService extends Service { await manager.drain() } + /** + * Close continuable admission below exact live parent Agents, stop only their + * visible descendant Activations synchronously, then await admitted scoped + * materializations and release those forests child-first. The scoped cutoff + * lasts until each exact parent leaves the registry; unrelated parent trees + * remain live. + * @param parents - exact host-owned parent Agents entering teardown. + * @returns once every retained descendant Activation released its `AgentHandle`. + * @throws an aggregate error after all scoped branches settle when any failed. + */ + async drainContinuableDescendants(parents: readonly Agent[]): Promise { + const manager = this.continuations + // Absent continuation services means nothing was ever materialized. + if (manager === undefined) return + await manager.drainDescendants(parents) + } + /** * Register a provider under its name. Registration is effect-scoped and HMR * safe; removing a provider blocks new starts but does not revoke runs that diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 79a1376b54..9c02a7ec97 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -663,6 +663,199 @@ describe('continuable durability and teardown', () => { expect(loaded.meta.id).toBe(started.childId) }) + it('drains one parent forest without disabling a sibling parent forest', async () => { + const releaseTarget = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() + const releaseSibling = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('target child'), gate: releaseTarget.promise }, + { chunks: textResponse('sibling child'), gate: releaseSibling.promise }, + { chunks: textResponse('target grandchild'), gate: releaseGrandchild.promise }, + { chunks: textResponse('sibling follow-up') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const siblingParent = ctx.agentLoop.create( + SessionId('sibling-parent'), + { provider: 'mock', model: 'mock' }, + ) + const target = await ctx.subagents.startContinuable(startSpec(parent)) + const sibling = await ctx.subagents.startContinuable(startSpec(siblingParent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + const targetChild = ctx.agents.get(target.childId)! + const siblingChild = ctx.agents.get(sibling.childId)! + const grandchild = await ctx.subagents.startContinuable(startSpec(targetChild)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) }) + const cancellations: SessionId[] = [] + ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) }) + + const drained = ctx.subagents.drainContinuableDescendants([parent]) + const convergedDrain = ctx.subagents.drainContinuableDescendants([parent]) + + // The scoped cutoff stops only the selected forest. The sibling child stays + // resident and can accept later work while target cleanup is still blocked. + expect(cancellations).toEqual([target.childId, grandchild.childId]) + expect(ctx.agents.get(target.childId)).toBe(targetChild) + expect(ctx.agents.get(grandchild.childId)).toBeDefined() + expect(ctx.agents.get(sibling.childId)).toBe(siblingChild) + await expect(followup(ctx, siblingParent, sibling.childId, message('still live'))) + .resolves.toBeTypeOf('string') + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toMatchObject({ code: 'DRAINING' }) + await expect(followup(ctx, parent, target.childId, message('too late'))) + .rejects.toMatchObject({ code: 'DRAINING' }) + + releaseTarget.resolve(undefined) + releaseGrandchild.resolve(undefined) + await Promise.all([drained, convergedDrain]) + expect(ctx.agents.get(target.childId)).toBeUndefined() + expect(ctx.agents.get(grandchild.childId)).toBeUndefined() + expect(ctx.agents.get(sibling.childId)).toBe(siblingChild) + // The exact root remains closed until its host disposes it, even after all + // current descendants are gone. + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toMatchObject({ code: 'DRAINING' }) + + releaseSibling.resolve(undefined) + await waitNoActivation(ctx, sibling.childId) + }) + + it('retains a continuable root while draining only its descendants', async () => { + const releaseChild = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child'), gate: releaseChild.promise }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + const cancellations: SessionId[] = [] + ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) }) + + const drained = ctx.subagents.drainContinuableDescendants([child]) + + expect(cancellations).toEqual([grandchild.childId]) + expect(ctx.agents.get(started.childId)).toBe(child) + releaseGrandchild.resolve(undefined) + await drained + expect(ctx.agents.get(grandchild.childId)).toBeUndefined() + expect(ctx.agents.get(started.childId)).toBe(child) + await expect(ctx.subagents.startContinuable(startSpec(child))) + .rejects.toMatchObject({ code: 'DRAINING' }) + + releaseChild.resolve(undefined) + await waitNoActivation(ctx, started.childId) + }) + + it('finds scoped descendants after an intermediate one-shot Agent leaves the registry', async () => { + const releaseIntermediate = Promise.withResolvers() + const releaseDescendant = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('one-shot'), gate: releaseIntermediate.promise }, + { chunks: textResponse('continuable descendant'), gate: releaseDescendant.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const run = await ctx.subagents.start('spawn', { + prompt: message('one-shot task'), + parent, + signal: testSignal, + }) + const intermediate = run.localAgent + expect(intermediate).toBeDefined() + if (intermediate === undefined) throw new Error('spawn must publish a local Agent') + const descendant = await ctx.subagents.startContinuable(startSpec(intermediate)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + + const intermediateId = intermediate.id + const disposingIntermediate = run.dispose() + releaseIntermediate.resolve(undefined) + await disposingIntermediate + expect(ctx.agents.get(intermediateId)).toBeUndefined() + expect(ctx.agents.get(descendant.childId)).toBeDefined() + const cancellations: SessionId[] = [] + ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) }) + + const drained = ctx.subagents.drainContinuableDescendants([parent]) + + expect(cancellations).toEqual([descendant.childId]) + releaseDescendant.resolve(undefined) + await drained + expect(ctx.agents.get(descendant.childId)).toBeUndefined() + }) + + it('awaits and rolls back an admitted materialization below a scoped root', async () => { + const { ctx, parent } = await setup([]) + const manager = (ctx.subagents as unknown as { + continuations: { ownerCtx: Context } + }).continuations + const agents = manager.ownerCtx.agents + const create = agents.create.bind(agents) + const published = Promise.withResolvers() + const releaseMaterialization = Promise.withResolvers() + const createSpy = vi.spyOn(agents, 'create').mockImplementation(async (options) => { + const handle = await create(options) + published.resolve(handle.agent.id) + await releaseMaterialization.promise + return handle + }) + + try { + const starting = ctx.subagents.startContinuable(startSpec(parent)) + const childId = await published.promise + let drainResolved = false + const drained = ctx.subagents.drainContinuableDescendants([parent]).then(() => { + drainResolved = true + }) + await Promise.resolve() + expect(drainResolved).toBe(false) + + releaseMaterialization.resolve(undefined) + await expect(starting).rejects.toMatchObject({ code: 'DRAINING' }) + await drained + expect(ctx.agents.get(childId)).toBeUndefined() + } finally { + createSpy.mockRestore() + } + }) + + it('ignores a stale scoped root without disabling its live same-id Agent', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const stale = { ...parent, id: parent.id } as unknown as Agent + + await ctx.subagents.drainContinuableDescendants([stale]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + + await waitNoActivation(ctx, started.childId) + }) + + it('reports a scoped teardown failure after releasing the selected branch', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('target child'), gate: hold.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map Promise } }> } + }).continuations + const activation = manager.activations.get(started.childId)! + const realDispose = activation.handle.dispose.bind(activation.handle) + activation.handle.dispose = async () => { + await realDispose() + throw new Error('scoped child reap failed') + } + + const drained = ctx.subagents.drainContinuableDescendants([parent]) + hold.resolve(undefined) + + await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) + it('rejects new materialization and delivery once draining begins', async () => { const { ctx, parent } = await setup([textResponse('done')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 64c510bf40..0beb075237 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -119,10 +119,11 @@ describe('SubagentService', () => { expect('resume' in provider).toBe(false) }) - it('drains continuable activations as a no-op when no manager was bound', async () => { + it('treats global and scoped drains as no-ops when no manager was bound', async () => { const { subagents } = await service() // Without `ctx.agents` no manager exists, so nothing was ever materialized. await expect(subagents.drainContinuable()).resolves.toBeUndefined() + await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined() }) it('rejects continuable operations when their runtime services are absent', async () => { From f4a65a34e63c7debf01957baea8973e57a591ddf Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 31 Jul 2026 14:24:35 +0800 Subject: [PATCH 071/114] cleanup(subagent): hide manager-wide continuation drain --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 2 +- ...8-continuable-subagent-conversations.zh.md | 2 +- docs/cordis-catalog/services.md | 9 ---- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 -- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 1 - packages/subagent/subagent/README.zh.md | 1 - packages/subagent/subagent/src/index.ts | 14 ----- .../subagent/tests/continuation.spec.ts | 54 ++++++++++--------- .../subagent/subagent/tests/service.spec.ts | 4 +- 14 files changed, 41 insertions(+), 66 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 87101c479a..ee9124f314 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.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-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: 8e867254a726c57200936cff6c83831abb4c66ad -2026-07-28-continuable-subagent-conversations.zh.md: 3383a0b8fd7b17ad40c3a03f32a78df058afb120 +2026-07-28-continuable-subagent-conversations.md: 43abcd88d172104ee5bc55e5a99b4e0306d6dd12 +2026-07-28-continuable-subagent-conversations.zh.md: 57d09e176362c05791cdd6317d95b6a3f5082f40 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index 8e867254a7..43abcd88d1 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -107,7 +107,7 @@ Child release occurs only after the child Agent is quiescent, every child of tha Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. -Top-level teardown is host-owned rather than represented as another Activation. Manager unload uses `drainContinuable()` to close manager-wide admission synchronously, await every admitted materialization through publication or rollback, stop the stable live forest, and release it child-first. A host that owns selected top-level Agents instead uses `drainContinuableDescendants(parents)`: exact Agent identities close admission only below those roots until each leaves the registry, while unrelated forests and manager-wide admission remain live; the manager stops their visible descendants before its first await, waits only materializations admitted below those roots, and releases only the selected branches. Every materialized start and live delivery rechecks caller cancellation, the applicable draining scope, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after the applicable drain settles may the host dispose its top-level Agents; only the global drain precedes manager-scope disposal. +Top-level teardown is host-owned rather than represented as another Activation. Manager unload invokes its internal manager-wide drain to close admission synchronously, await every admitted materialization through publication or rollback, stop the stable live forest, and release it child-first. A host that owns selected top-level Agents uses `drainContinuableDescendants(parents)`: exact Agent identities close admission only below those roots until each leaves the registry, while unrelated forests and manager-wide admission remain live; the manager stops their visible descendants before its first await, waits only materializations admitted below those roots, and releases only the selected branches. Every materialized start and live delivery rechecks caller cancellation, the applicable draining scope, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after the applicable drain settles may the host dispose its top-level Agents; only manager-wide drain precedes manager-scope disposal. The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. Each materialization registers its barrier participant and snapshots its exact live ancestry before starting the inner transaction, then remains tracked until it installs an Activation or fully rolls back. The Activation retains weak membership of that ancestry, so an intermediate Agent may leave the registry without hiding a still-live descendant from its host root. Each Activation installs one memoized disposal promise before cancellation or recursive callbacks, allowing scoped host shutdown, global manager unload, child release, and normal settlement to converge without double release. Cancellation propagates top-down before slow descendant cleanup; handle release remains child-first. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining selected handles, and the aggregate drain reports failure after all selected branches settle. Durable child Sessions survive this process-local teardown. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 3383a0b8fd..57d09e1763 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -107,7 +107,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 -顶层拆卸由宿主负责,而不表示为另一次激活。管理器卸载使用 `drainContinuable()` 同步关闭管理器全局准入,等待每个已获准的物化过程完成发布或回滚,停止稳定的在线森林,并按 child-first 顺序释放。拥有选定顶层 Agent 的宿主则使用 `drainContinuableDescendants(parents)`:确切的 Agent 身份只关闭这些根之下的准入,直到每个身份离开注册表,而无关森林和管理器全局准入保持在线;管理器会在第一次 await 之前停止其可见后代,只等待这些根之下已获准的物化过程,并且只释放选定分支。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、适用的 draining 作用域、Activation dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有适用的 drain 结算后,宿主才能 dispose 自己的顶层 Agent;只有全局 drain 会先于管理器作用域 dispose。 +顶层拆卸由宿主负责,而不表示为另一次激活。管理器卸载会调用其内部的管理器全局 drain,同步关闭准入,等待每个已获准的物化过程完成发布或回滚,停止稳定的在线森林,并按 child-first 顺序释放。拥有选定顶层 Agent 的宿主使用 `drainContinuableDescendants(parents)`:确切的 Agent 身份只关闭这些根之下的准入,直到每个身份离开注册表,而无关森林和管理器全局准入保持在线;管理器会在第一次 await 之前停止其可见后代,只等待这些根之下已获准的物化过程,并且只释放选定分支。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、适用的 draining 作用域、Activation dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有适用的 drain 结算后,宿主才能 dispose 自己的顶层 Agent;只有管理器全局 drain 会先于管理器作用域 dispose。 activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。每个物化过程都会在启动内部事务前注册其屏障参与项,并对其确切的在线祖先建立快照,然后保持跟踪,直到安装 Activation 或完全回滚。Activation 会以弱引用方式记录其属于这组祖先,因此中间 Agent 即使离开注册表,也不会让仍在线的后代脱离宿主根节点的可见范围。每个 Activation 都会在取消或递归回调前安装一个记忆化的 dispose promise,使限定作用域的宿主关闭、全局管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。取消会在等待缓慢的后代清理之前自顶向下传播;handle 释放仍是 child-first。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余选中 handle,聚合 drain 则在所有选中分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d574b8b1dd..bcba4fe94f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1980,15 +1980,6 @@ async startContinuable(spec: ContinuableStartSpec): Promise */ async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise -/** - * Close continuable admission synchronously, then dispose every live - * Activation forest child-first. A host calls this before disposing top-level - * agents so no descendant outlives the runtime that owns its teardown. - * @returns once every live Activation released its `AgentHandle`. - * @throws an aggregate error after all branches settle when any failed. - */ -async drainContinuable(): Promise - /** * Close continuable admission below exact live parent Agents, stop only their * visible descendant Activations synchronously, then await admitted scoped diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 1e0a7546d3..6bd9d32400 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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/subagent.md -subagent.md: eff78bae6fcf7440cce66f122771ff623a5eb3c7 -subagent.zh.md: a898f438c257db05a3d84e6318ce23e1f366e193 +subagent.md: 379bc9fdd5ff14f9d516d3ddcf37a353f5318026 +subagent.zh.md: 3cd43deadba095e67e6a0dd3b483ea676206fbbf diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index eff78bae6f..379bc9fdd5 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -129,7 +129,7 @@ For both operations the caller signal owns lookup, materialization, and admissio Every Activation owns its `AgentHandle` and an `ownedChildren: Set`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens only after the child Agent is quiescent, every child of that child is disposed, the final durability checkpoint settles, and the child's `AgentHandle` completes disposal. -Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. `drainContinuable()` closes manager-wide admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown. +Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. Manager unload invokes an internal manager-wide drain that closes admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown. ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index a898f438c2..3cd43deadb 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -129,7 +129,7 @@ Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 ` 每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、最终的持久性检查点结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。 -只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。`drainContinuable()` 会关闭管理器全局准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。 +只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。管理器卸载会调用内部的管理器全局 drain,关闭准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。 ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0990ff339a..d8ce1b4150 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -892,10 +892,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so every accepted message has\n * one observable order.\n * @param parent - the exact live direct parent authorizing this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, parent authority is\n * rejected, or the message was not admitted.\n */', }, - { - signature: 'async drainContinuable(): Promise', - jsDoc: '/**\n * Close continuable admission synchronously, then dispose every live\n * Activation forest child-first. A host calls this before disposing top-level\n * agents so no descendant outlives the runtime that owns its teardown.\n * @returns once every live Activation released its `AgentHandle`.\n * @throws an aggregate error after all branches settle when any failed.\n */', - }, { signature: 'async drainContinuableDescendants(parents: readonly Agent[]): Promise', jsDoc: '/**\n * Close continuable admission below exact live parent Agents, stop only their\n * visible descendant Activations synchronously, then await admitted scoped\n * materializations and release those forests child-first. The scoped cutoff\n * lasts until each exact parent leaves the registry; unrelated parent trees\n * remain live.\n * @param parents - exact host-owned parent Agents entering teardown.\n * @returns once every retained descendant Activation released its `AgentHandle`.\n * @throws an aggregate error after all scoped branches settle when any failed.\n */', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 4a0496100e..83088e9c21 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 6fab6859e2c15fdb1ded023642cbc593e0457384 -README.zh.md: 1f59807a545dcb1fafbac3f301746c7217d15f3a +README.md: cea62b90a4c5cb3c4ec74c98f4272daefe78b38e +README.zh.md: 73256ef503c23d391a2c6186c36f4ac933929c8a diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 6fab6859e2..cea62b90a4 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -31,7 +31,6 @@ Multiple providers may coexist under different names. This lets a deployment exp | `start(name, request)` | Validate an ordinary caller request, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. | | `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. | | `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. | -| `drainContinuable()` | Close continuable admission synchronously, await every materialization that already passed admission through publication or rollback, then dispose the stable live Activation forest child-first. A host calls this before disposing top-level agents so no descendant outlives the runtime that owns its teardown. An aggregate error surfaces after every branch settles when any failed. | | `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. | `SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 1f59807a54..73256ef503 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -31,7 +31,6 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 | | `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | | `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | -| `drainContinuable()` | 同步关闭可继续准入,等待每个已经通过准入的物化过程完成发布或回滚,然后按 child-first 顺序 dispose 稳定的在线 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 | | `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 | `SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。 diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 81d88f9d29..91cda659c8 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -201,20 +201,6 @@ export class SubagentService extends Service { return this.requireContinuations().followup(parent, childId, content, options) } - /** - * Close continuable admission synchronously, then dispose every live - * Activation forest child-first. A host calls this before disposing top-level - * agents so no descendant outlives the runtime that owns its teardown. - * @returns once every live Activation released its `AgentHandle`. - * @throws an aggregate error after all branches settle when any failed. - */ - async drainContinuable(): Promise { - const manager = this.continuations - // Absent continuation services means nothing was ever materialized. - if (manager === undefined) return - await manager.drain() - } - /** * Close continuable admission below exact live parent Agents, stop only their * visible descendant Activations synchronously, then await admitted scoped diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 9c02a7ec97..dc899f778c 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -122,6 +122,18 @@ function followup( }) } +/** + * Exercise manager-wide teardown through the package-private owner rather than + * adding the irreversible operation to the public service contract. + */ +function drainManager(ctx: Context): Promise { + const manager = (ctx.subagents as unknown as { + continuations?: { drain(): Promise } + }).continuations + if (manager === undefined) throw new Error('expected a bound continuation manager') + return manager.drain() +} + /** Wait until a child's Activation is gone, i.e. its handle finished disposal. */ async function waitNoActivation(ctx: Context, childId: SessionId): Promise { await vi.waitFor(() => { @@ -245,7 +257,7 @@ describe('SubagentService.startContinuable', () => { expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) }) expect(ends).toEqual([]) - await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined() + await expect(drainManager(ctx)).resolves.toBeUndefined() }) it('rejects a continuable child that would exceed the configured depth cap', async () => { @@ -283,7 +295,7 @@ describe('SubagentService.startContinuable', () => { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', }) - await ctx.subagents.drainContinuable() + await drainManager(ctx) }) it('records a declared tool filter in the descriptor', async () => { @@ -316,7 +328,7 @@ describe('SubagentService.startContinuable', () => { provider: 'spawn', toolFilter: { deny: ['noop'] }, }) - await ctx.subagents.drainContinuable() + await drainManager(ctx) }) it('cold-resumes without inventing a model route the descriptor never declared', async () => { @@ -341,7 +353,7 @@ describe('SubagentService.startContinuable', () => { }) expect(resumed.options.provider).toBeUndefined() expect(resumed.options.model).toBeUndefined() - await fresh.subagents.drainContinuable() + await drainManager(fresh) }) it('numbers the descriptor turn after an inherited fork prefix', async () => { @@ -649,7 +661,7 @@ describe('continuable durability and teardown', () => { const disposals: SessionId[] = [] ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) }) - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) // Let the held model call observe its cancellation so quiescence can settle. hold.resolve(undefined) await drained @@ -861,7 +873,7 @@ describe('continuable durability and teardown', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) - await ctx.subagents.drainContinuable() + await drainManager(ctx) await expect(ctx.subagents.startContinuable(startSpec(parent))) .rejects.toMatchObject({ code: 'DRAINING' }) @@ -873,7 +885,7 @@ describe('continuable durability and teardown', () => { const { ctx, parent } = await setup([]) const drains: Promise[] = [] const accepted: MessageId[] = [] - ctx.on('subagent/start', () => { drains.push(ctx.subagents.drainContinuable()) }) + ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) }) ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) }) await expect(ctx.subagents.startContinuable(startSpec(parent))) @@ -890,7 +902,7 @@ describe('continuable durability and teardown', () => { const drains: Promise[] = [] ctx.on('agent/created', (child) => { if (child === parent) return - const draining = ctx.subagents.drainContinuable().then(() => { order.push('drain') }) + const draining = drainManager(ctx).then(() => { order.push('drain') }) drains.push(draining) }) ctx.on('agent/disposed', (child) => { @@ -926,7 +938,7 @@ describe('continuable durability and teardown', () => { // Let the child-lock operation reach the live admission cutoff. Admission // and inbox submission must then complete in one synchronous span. await Promise.resolve() - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await expect(delivery).resolves.toBeTypeOf('string') @@ -943,7 +955,7 @@ describe('continuable durability and teardown', () => { // Accepted into the inbox, but this queued turn never opens. await followup(ctx, parent, started.childId, message('never logged')) - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await drained await waitNoActivation(ctx, started.childId) @@ -1024,7 +1036,7 @@ describe('continuable review regressions', () => { expect(activation.accepted.size).toBe(0) child.followup = realFollowup - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await drained }) @@ -1131,7 +1143,7 @@ describe('continuable review regressions', () => { throw new Error('scoped cleanup failed') } - await expect(ctx.subagents.drainContinuable()).rejects.toThrow() + await expect(drainManager(ctx)).rejects.toThrow() await vi.waitFor(() => { expect(ends).toHaveLength(1) }) // Emitting before disposal would have reported this failed epoch as success. expect(ends[0]!.stopReason).toBe('error') @@ -1153,7 +1165,7 @@ describe('continuable review regressions', () => { const activation = manager.activations.get(started.childId)! activation.observer.capture = () => { throw new Error('capture failed') } - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) await vi.waitFor(() => { expect(ends).toHaveLength(1) }) @@ -1177,7 +1189,7 @@ describe('continuable review regressions', () => { }) child.ctx.on('agent/cancel-requested', () => { order.push('cancel') }) - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await drained @@ -1196,7 +1208,7 @@ describe('continuable review regressions', () => { // Activation must still reach settlement instead of waiting on that id. await followup(ctx, parent, started.childId, message('discarded')) - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await drained @@ -1456,7 +1468,7 @@ describe('continuable errors', () => { }) // Begin the parent Activation's teardown, then try to give it a child. - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) await expect(ctx.subagents.startContinuable(startSpec(child))) .rejects.toMatchObject({ code: 'DRAINING' }) hold.resolve(undefined) @@ -1490,7 +1502,7 @@ describe('continuable errors', () => { throw new Error('grandchild reap failed') } - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) // The other branch still released, and durable sessions survive. @@ -1552,14 +1564,6 @@ describe('continuable errors', () => { await waitNoActivation(ctx, started.childId) }) - it('drains without continuation services as a no-op', async () => { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SubagentService) - // No `ctx.agents`, so no manager was ever bound and nothing was materialized. - await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined() - }) - it('unloading the manager drains its live activations', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 0beb075237..44c230fdc1 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -119,10 +119,10 @@ describe('SubagentService', () => { expect('resume' in provider).toBe(false) }) - it('treats global and scoped drains as no-ops when no manager was bound', async () => { + it('does not expose manager teardown and treats a scoped drain as a no-op when no manager was bound', async () => { const { subagents } = await service() // Without `ctx.agents` no manager exists, so nothing was ever materialized. - await expect(subagents.drainContinuable()).resolves.toBeUndefined() + expect('drainContinuable' in subagents).toBe(false) await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined() }) From 9cd3c57b751a7df6f4d97813186e7ccc139a5e9f Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 1 Aug 2026 08:04:02 +0800 Subject: [PATCH 072/114] test(subagent): align continuation fixtures with session API --- .../subagent-fork/tests/subagent-fork.spec.ts | 2 +- .../subagent/tests/continuation.spec.ts | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 924ed2df62..a11a5fac86 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -226,7 +226,7 @@ describe('dsh-subagent-fork', () => { expect(fresh.seed).toBeUndefined() // Complete one parent turn, then the prefix is captured once at creation. - parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }) + parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })) await parent.whenIdle() const seeded = await provider.prepareContinuable!({ sessionId: SessionId('continuable-seeded'), diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index dc899f778c..2369e0bb21 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -12,7 +12,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import InvariantService from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -356,24 +356,26 @@ describe('SubagentService.startContinuable', () => { await drainManager(fresh) }) - it('numbers the descriptor turn after an inherited fork prefix', async () => { + it('continues turn numbering after an inherited fork prefix and pre-turn descriptor', async () => { const { ctx, parent } = await setup([ textResponse('parent turn'), textResponse('forked child'), ]) // Complete one parent turn so fork has a prefix to contribute. - parent.followup({ content: message('parent work'), source: { kind: 'user' } }) + parent.followup(createUserMessage({ content: message('parent work'), source: { kind: 'user' } })) await parent.whenIdle() const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - const descriptorTurn = loaded.events.find(event => event.type === 'turn/start' - && event.data.trigger.kind === 'subagent-descriptor') - // The seeded descriptor turn continues the inherited numbering rather than - // restarting at 1, so the replayed child log stays balanced. - expect(descriptorTurn?.type === 'turn/start' && descriptorTurn.data.turn).toBe(2) + const descriptorIndex = loaded.events.findIndex(event => event.type === 'subagent/descriptor') + const childTurn = loaded.events.slice(descriptorIndex + 1) + .find(event => event.type === 'turn/start') + // The first child turn after the descriptor continues the inherited prefix + // rather than restarting at 1, so the replayed child log stays balanced. + expect(descriptorIndex).toBeGreaterThanOrEqual(0) + expect(childTurn?.type === 'turn/start' && childTurn.data.turn).toBe(2) expect(loaded.meta.seedLength).toBeGreaterThan(0) }) From 4d0a24d8ed13d081c662d500dab7fc28676dc55b Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 1 Aug 2026 08:44:36 +0800 Subject: [PATCH 073/114] test(subagent): preserve durability failure after rebase --- .../fixtures/subagent-durability-failure.ts | 2 +- .../subagent-continuable/session.1.jsonl | 63 ++++++----- .../subagent-continuable/session.jsonl | 106 +++++++++--------- 3 files changed, 87 insertions(+), 84 deletions(-) diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 8352dac005..5499936667 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -22,7 +22,7 @@ export const inject = ['sessionPersistence', 'subagents'] const PLACEHOLDER_CHILD_ID = '33333333-3333-4333-8333-333333333333' const UNKNOWN_CHILD_ID = '22222222-2222-4222-8222-222222222222' /** The child continuation turn whose durability checkpoint is forced to fail. */ -const FAILED_CHECKPOINT_TURN = 4 +const FAILED_CHECKPOINT_TURN = 3 /** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */ export function apply(ctx: Context): void { diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index a5220ed410..653f212a06 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -1,31 +1,32 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"/tmp/subagent-continuable","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"subagent-descriptor"}}} -{"type":"subagent/descriptor","seq":1,"time":1789000000002,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek","agentModel":"deepseek-v4-flash"}} -{"type":"turn/end","seq":2,"time":1789000000003,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":3,"time":1789000000004,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":4,"time":1789000000005,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1789000000005,"data":{"title":"Reply with exactly the word","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":6,"time":1789000000006,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":7,"time":1789000000007,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":8,"time":1789000000008,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":9,"time":1789000000009,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":10,"time":1789000000010,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":11,"time":1789000000011,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":12,"time":1789000000012,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":13,"time":1789000000013,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1789000000014,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":15,"time":1789000000015,"data":{"turn":2,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":16,"time":1789000000016,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} -{"type":"user/message","seq":17,"time":1789000000017,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append"} -{"type":"step/start","seq":18,"time":1789000000018,"data":{"turn":3,"step":1}} -{"type":"assistant/chunk","seq":19,"time":1785394678743,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":20,"time":1789000000020,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} -{"type":"assistant/chunk","seq":21,"time":1789000000021,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} -{"type":"assistant/chunk","seq":22,"time":1789000000022,"data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":23,"time":1789000000023,"data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":24,"time":1785394678743,"data":{"turn":3,"step":1,"content":[{"type":"text","text":"SECOND_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":1785394678743,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":26,"time":1785394678743,"data":{"turn":3,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":27,"time":1785394678756,"data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} -{"type":"user/message","seq":28,"time":1785394678756,"data":{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append"} -{"type":"turn/end","seq":29,"time":1785394678762,"data":{"turn":4,"reason":{"kind":"error","step":1,"message":"snapshot disk full"}}} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"subagent/descriptor","seq":0,"time":1785544945198,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"session/end-seed","seq":1,"time":1785544945198,"data":{}} +{"type":"turn/start","seq":2,"time":1785544945199,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":3,"time":1785544945199,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"cd28c816-821e-412c-bc7f-404bbb599641"},"surfaceOp":"append"} +{"type":"session/title","seq":4,"time":1785544945199,"data":{"title":"Reply with exactly the word","messageSeqs":[3],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":5,"time":1789000000005,"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":"2ea12eb1-e86f-447a-8574-63f2d819c689"},"surfaceOp":"append"} +{"type":"step/start","seq":6,"time":1785544945227,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":7,"time":1785544945227,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785544945227,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":11,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":12,"time":1789000000011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1789000000012,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":1789000000013,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"99ab55a3-f42f-4816-8fff-3b3bcb15fa6b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1789000000014,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":1789000000015,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":17,"time":1789000000016,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} +{"type":"user/message","seq":18,"time":1789000000017,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"70a11623-f9c9-43d1-bad6-9bf45d19dd90"},"surfaceOp":"append"} +{"type":"step/start","seq":19,"time":1789000000018,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":20,"time":1785394678743,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":21,"time":1789000000020,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} +{"type":"assistant/chunk","seq":22,"time":1789000000021,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} +{"type":"assistant/chunk","seq":23,"time":1789000000022,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":24,"time":1789000000023,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":25,"time":1785394678743,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"86c9fc2b-900b-4a84-9089-dd4b8ed3d2d6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":1785394678743,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":27,"time":1785394678743,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":28,"time":1785394678756,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} +{"type":"user/message","seq":29,"time":1785394678756,"data":{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"c3a91e09-99fc-4303-92e4-d8e89cb767f4"},"surfaceOp":"append"} +{"type":"turn/end","seq":30,"time":1785545035946,"data":{"turn":3,"reason":{"kind":"error","step":1,"message":"snapshot disk full"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index 4dff6a044a..4d0602e8df 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -1,54 +1,56 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"/tmp/subagent-continuable","delegationDepth":0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1789000000002,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1789000000002,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"9472efc7-dd29-439f-8387-9b2dee43cd33"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1789000000003,"data":{"title":"Follow these steps exactly, then","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1789000000004,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1789000000005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1789000000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} -{"type":"assistant/chunk","seq":7,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} -{"type":"assistant/chunk","seq":8,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":9,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":1789000000011,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":1789000000012,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} -{"type":"tool/result","seq":12,"time":1789000000013,"data":{"turn":1,"step":1,"callId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"step/end","seq":13,"time":1789000000014,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":14,"time":1789000000015,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":15,"time":1785394678688,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":16,"time":1789000000017,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_1","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}} -{"type":"assistant/chunk","seq":17,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}}} -{"type":"assistant/chunk","seq":18,"time":1789000000019,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":19,"time":1789000000020,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":20,"time":1785394678689,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"tool/call","seq":21,"time":1785394678689,"data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}} -{"type":"tool/result","seq":22,"time":1785394678701,"data":{"turn":1,"step":2,"callId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} -{"type":"step/end","seq":23,"time":1785394678701,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":24,"time":1785394678713,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":25,"time":1785394678718,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":26,"time":1785394678719,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_2","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}} -{"type":"assistant/chunk","seq":27,"time":1789000000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}}} -{"type":"assistant/chunk","seq":28,"time":1789000000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":29,"time":1789000000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":30,"time":1785394678719,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} -{"type":"tool/call","seq":31,"time":1785394678719,"data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}} -{"type":"tool/result","seq":32,"time":1785394678733,"data":{"turn":1,"step":3,"callId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1785394678733,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":34,"time":1785394678746,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":35,"time":1785394678752,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":36,"time":1785394678753,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_unknown","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}} -{"type":"assistant/chunk","seq":37,"time":1785394678753,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} -{"type":"assistant/chunk","seq":38,"time":1789000000039,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":39,"time":1789000000040,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":40,"time":1785394678753,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} -{"type":"tool/call","seq":41,"time":1785394678753,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} -{"type":"tool/result","seq":42,"time":1785394678765,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","content":[{"type":"text","text":"Error: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true,"error":{"name":"SubagentError","code":"NOT_RESUMABLE"}},"sourceEventSeqs":[41],"surfaceOp":"append"} -{"type":"step/end","seq":43,"time":1785394678765,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":44,"time":1785394678774,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":45,"time":1785394678778,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":46,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":47,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":48,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":49,"time":1789000000050,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":50,"time":1785394678779,"data":{"turn":1,"step":5,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} -{"type":"step/end","seq":51,"time":1785394678779,"data":{"turn":1,"step":5}} -{"type":"turn/end","seq":52,"time":1785394678779,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":3,"time":1785544945178,"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":"d28c0ecc-be25-4d19-9834-ad72889ddaa3"},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1785544945178,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785544945179,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":6,"time":1785544945179,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":7,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} +{"type":"assistant/chunk","seq":9,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} +{"type":"assistant/chunk","seq":10,"time":1785544945188,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":11,"time":1785544945188,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":1785544945188,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3a4667fb-352d-4ee7-ab80-42cf1dd6fb35"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":1785544945188,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} +{"type":"tool/result","seq":14,"time":1785544945199,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"3567aec2-7919-4813-a15d-c5e9021f6968"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1785544945199,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":16,"time":1785544945207,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":17,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":18,"time":1789000000019,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_1","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}} +{"type":"assistant/chunk","seq":19,"time":1789000000020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}}} +{"type":"assistant/chunk","seq":20,"time":1785544945212,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":21,"time":1785544945212,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":22,"time":1785544945212,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"beccc09f-a7ad-4537-ba2d-756961723dd4"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} +{"type":"tool/call","seq":23,"time":1785544945212,"data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}} +{"type":"tool/result","seq":24,"time":1785544945224,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_followup_1"},"content":[{"type":"tool-result","toolCallId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"fd85eacb-71f4-4a33-a512-b2e0c3040f65"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785544945224,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":26,"time":1785544945236,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":27,"time":1789000000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":28,"time":1789000000029,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_2","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}} +{"type":"assistant/chunk","seq":29,"time":1789000000030,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}}} +{"type":"assistant/chunk","seq":30,"time":1785544945241,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":31,"time":1785544945242,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":32,"time":1785544945242,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5b3e3797-a438-4751-8328-430cb4dc8689"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} +{"type":"tool/call","seq":33,"time":1785544945242,"data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}} +{"type":"tool/result","seq":34,"time":1785544945255,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_followup_2"},"content":[{"type":"tool-result","toolCallId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"cfb87156-ab7e-4641-a99b-245215621b90"}},"sourceEventSeqs":[33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1785544945255,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":36,"time":1785544945267,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":37,"time":1785394678753,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":38,"time":1789000000039,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_unknown","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}} +{"type":"assistant/chunk","seq":39,"time":1789000000040,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} +{"type":"assistant/chunk","seq":40,"time":1785544945273,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":41,"time":1785544945273,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":42,"time":1785544945273,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"52f1ebed-7577-4007-a07a-00f6a603c2f0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[37,38,39,40,41],"surfaceOp":"append"} +{"type":"tool/call","seq":43,"time":1785544945273,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} +{"type":"tool/result","seq":44,"time":1785544945285,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_followup_unknown"},"content":[{"type":"tool-result","toolCallId":"call_followup_unknown","content":[{"type":"text","text":"Error: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true}],"role":"user","id":"eaf26a9c-d339-4fa3-900a-9e47d23cccaf"},"error":{"name":"SubagentError","code":"NOT_RESUMABLE"}},"sourceEventSeqs":[43],"surfaceOp":"append"} +{"type":"step/end","seq":45,"time":1785544945285,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":46,"time":1785544945297,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":47,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":48,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":49,"time":1789000000050,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":50,"time":1785544945303,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":51,"time":1785544945303,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":52,"time":1785544945303,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fe4182fd-2de4-4e8d-9770-cb221b2b416a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1785544945303,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":54,"time":1785544945303,"data":{"turn":1,"reason":{"kind":"completed"}}} From 7b3920801b8b891d2b23240f16a92ae37703dc35 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 1 Aug 2026 09:17:12 +0800 Subject: [PATCH 074/114] fix(subagent): deduplicate activation teardown --- .../subagent/subagent/src/continuation.ts | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 2bd89607f8..03b4c34a31 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -404,22 +404,7 @@ export class SubagentContinuationManager { for (const child of activation.ownedChildren) owned.add(child) } const roots = [...this.activations.values()].filter(activation => !owned.has(activation.childId)) - const failures = await Promise.all(roots.map(async (activation) => { - try { - await this.dispose(activation) - return undefined - } catch (error: unknown) { - return error - } - })) - const reasons = failures.filter(failure => failure !== undefined) - if (reasons.length > 0) { - throw new SubagentError( - `continuable subagent teardown failed for ${reasons.length} activation(s): ` - + reasons.map(reason => errorChain(reason)).join('; '), - 'ACTIVATION_TEARDOWN_FAILED', - ) - } + await this.disposeRoots(roots, 'activation(s)') } /** @@ -481,7 +466,15 @@ export class SubagentContinuationManager { } await Promise.all(materializations.map(materialization => materialization.settled)) - const failures = await Promise.all(targetRoots.map(async (activation) => { + await this.disposeRoots(targetRoots, 'scoped activation(s)') + } + + /** Dispose independent roots and report every branch failure after all settle. */ + private async disposeRoots( + roots: readonly Activation[], + failureSubject: 'activation(s)' | 'scoped activation(s)', + ): Promise { + const failures = await Promise.all(roots.map(async (activation) => { try { await this.dispose(activation) return undefined @@ -492,7 +485,7 @@ export class SubagentContinuationManager { const reasons = failures.filter(failure => failure !== undefined) if (reasons.length > 0) { throw new SubagentError( - `continuable subagent teardown failed for ${reasons.length} scoped activation(s): ` + `continuable subagent teardown failed for ${reasons.length} ${failureSubject}: ` + reasons.map(reason => errorChain(reason)).join('; '), 'ACTIVATION_TEARDOWN_FAILED', ) From fc59b63c8c4ab4fe797896cc5b4783d774034f7d Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 22 Jul 2026 16:18:04 +0800 Subject: [PATCH 075/114] docs: propose durable subagent catalog --- ...subagent-catalog-and-list-agents.i18n.yaml | 6 ++ ...urable-subagent-catalog-and-list-agents.md | 66 +++++++++++++++++++ ...ble-subagent-catalog-and-list-agents.zh.md | 66 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 .agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md create mode 100644 .agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml new file mode 100644 index 0000000000..bf9a1ca9ff --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-22-durable-subagent-catalog-and-list-agents.md: 8ffc83e26121d7d1b542e549235290c226739a94 +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 5011fc05537ef5e8a3310c5b191226e46c98f40d diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md new file mode 100644 index 0000000000..8ffc83e261 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -0,0 +1,66 @@ +# Agent Note: Durable subagent catalog and list_agents + +Status: proposed + +English | [中文](2026-07-22-durable-subagent-catalog-and-list-agents.zh.md) + +## Problem + +Continuable background subagents expose a stable child id and persist the reconstruction descriptor in that child's session, so `send_message` can resume a known child without any listing operation. `list_agents` has a different requirement: after parent restart, enumerate only that parent's direct continuable children even when the caller no longer knows their ids. The durable child-handle and activation design is owned by [continuable background subagents](2026-07-21-continuable-background-subagents.md); this note owns enumeration and its model-facing query. + +Enumeration must cross-check immutable session lineage, descriptor validity, and process-local activation state without loading or resuming an Agent merely to display it. It must also define how missing, corrupt, deleted, or unsupported children affect the list and whether repeatedly loading many child logs needs an index. + +## Proposal + +Treat parent-to-child enumeration and `list_agents` as one separately reviewed feature built on the durable child-handle contract. `SubagentControlService.listChildren(parent)` must: + +- find materialized session headers whose `parentSession` identifies the caller's session; +- load and validate each candidate's `subagent/descriptor` event without activating the child; +- exclude sessions that are one-shot, corrupt, unsupported, missing, or not direct children; +- overlay the process-local Task association without treating it as durable state. + +Descriptor format, persistence, by-id lookup, direct-parent authorization, and cold resume remain owned by the activation proposal. Listing consumes those facts but cannot weaken them or invent a second descriptor representation. + +### Enumeration decision + +The first implementation uses `SessionPersistence.list()` to obtain materialized headers, filters on `SessionHeader.parentSession`, and calls `load()` only for those direct-child candidates to fold their descriptors. The activation contract calls a preallocated id without a durable header and descriptor an **unmaterialized child**: by-id control reports it as unavailable, while persistence listing omits it. A materialized one-shot child lacks the descriptor and is excluded. This path requires no parent-session catalog event or new persistence backend. + +This O(number of direct children) load path is the correctness baseline. If measured scale later requires an index, that index is derived state: session headers and child descriptors remain authoritative, and rebuilding or corruption fallback must reproduce the same results. An index cannot become a second authorization source or make an unmaterialized child visible. + +Listing adds no session event and no surface node. It reads the model-hidden descriptor retained in the child log by the activation contract, so compacted and uncompacted children must enumerate identically. + +### `list_agents` contract + +`SubagentControlService.listChildren(parent)` returns only durable direct children that carry a valid continuable descriptor, then overlays the process-local Task association. The model-facing `list_agents` tool is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control` and reports two operational states: + +- `running`: a non-terminal Task-backed activation exists, including startup and settlement before Task terminal publication; +- `resumable`: a valid durable descriptor exists and no activation is associated. + +These values are not `AgentStatus`. A plain Agent registry entry without a Task association is an ownership conflict, not a third list state. Corrupt, unsupported-version, wrong-parent, or missing-child descriptors fail explicitly rather than being silently advertised as resumable. + +The first version is read-only and has no child deletion operation. If later product behavior deletes child sessions, persistence listing naturally drops a deleted child; any future derived index must remove or tombstone the same entry so `list_agents` cannot retain stale state. + +## Alternatives considered + +**Fold listing into the activation RFC.** Descriptor-by-id persistence and cold resume do not require parent-to-child enumeration. Keeping the query separate lets `send_message` land without taking on listing states, scanning performance, or deletion behavior. + +**List every persisted session whose header names the parent.** `parentSession` proves lineage but does not prove that the child is continuable. Listing must also load and validate the descriptor. + +**Use the live Agent registry as the catalog.** Runs are deliberately disposed after every Task, and registry state disappears on restart. It cannot support durable discovery. + +**Persist a parent-session catalog event.** Direct-child headers already provide the durable enumeration seed, and the child descriptor is the reconstruction authority. A second parent log duplicates state and creates cross-session ordering and stale-entry behavior without helping by-id resume. + +## Acceptance criteria + +- Enumeration uses materialized session headers as candidates, validates `parentSession`, and includes only children whose persisted descriptor satisfies the durable child-handle contract. +- Listing loads no Agent, appends no session event, and returns the same children from compacted and uncompacted logs. +- `list_agents` returns only valid direct continuable children and reports `running` or `resumable`, with no pass-through runtime status. +- Parent resume does not activate children; listing reads durable state and overlays only already-associated process-local Tasks. +- A preallocated-but-unmaterialized child id, one-shot child, corrupt descriptor, unsupported descriptor version, wrong-parent child, and stale derived-index entry are never advertised as resumable. +- Keyless tests cover fresh and compacted discovery, restart, wrong-parent access, unsupported descriptors, scan behavior, and stale-index fallback. The model-facing tool has runnable snapshot coverage. + +## Risks + +- Listing performs one header scan and may load every direct-child log; a later derived index must preserve the same authorization, corruption, and fallback behavior. +- The first version has no deletion operation, so persisted children remain listed for as long as their sessions remain in persistence even though live Agent resources remain bounded by active Tasks. +- Task associations exist only in one runtime. Another process can report a durable child as `resumable` while work for that child is active elsewhere unless the deployment adds a shared lease. diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md new file mode 100644 index 0000000000..5011fc0553 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -0,0 +1,66 @@ +# Agent Note(agent 决策记录):持久化 subagent 目录与 list_agents + +Status: proposed + +[English](2026-07-22-durable-subagent-catalog-and-list-agents.md) | 中文 + +## 问题 + +可继续的后台 subagent 会公开稳定的 child id,并将重建描述符持久化在该 child 的会话中,因此 `send_message` 无需任何列表查询操作即可恢复已知 child。`list_agents` 的要求不同:parent 重启后,即使调用方不再知道各 child id,也要只枚举该 parent 的直接可继续 child。[可继续的后台 subagent](2026-07-21-continuable-background-subagents.md)负责持久化 child handle 与激活设计;本记录负责枚举及其面向模型的查询。 + +枚举必须交叉核对不可变的会话谱系、描述符有效性与进程内激活状态,而不能仅为展示就加载或恢复 Agent。它还必须定义缺失、损坏、已删除或不受支持的 child 如何影响列表,以及反复加载大量 child 日志是否需要索引。 + +## 提案 + +将 parent 到 child 的枚举与 `list_agents` 作为一个基于持久化 child handle 契约、单独评审的功能。`SubagentControlService.listChildren(parent)` 必须: + +- 查找 `parentSession` 将调用方会话标识为 parent 的已实际落盘会话 header; +- 加载并校验每个候选会话的 `subagent/descriptor` 事件,但不激活 child; +- 排除一次性、损坏、不受支持、缺失或并非直接 child 的会话; +- 叠加进程内 Task 关联,但不将该关联视为持久化状态。 + +描述符格式、持久化、按 id 查找、直接 parent 鉴权与从持久化存储恢复仍由激活提案负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。 + +### 枚举决策 + +第一版使用 `SessionPersistence.list()` 获取已实际落盘的 header,按 `SessionHeader.parentSession` 过滤,并且只对这些直接 child 候选调用 `load()` 来归并其描述符。激活契约将已预分配 id、却没有持久化 header 和描述符的 child 称为 **unmaterialized child**:按 id 的控制操作会报告该 id 不可用,持久化列表则不会列出它。已实际落盘的一次性 child 没有描述符,因此会被排除。这条路径无需 parent 会话目录事件或新的持久化后端。 + +这条 O(直接 child 数量)加载路径是正确性基线。如果实测规模日后需要索引,该索引属于派生状态:会话 header 和 child 描述符仍是权威信息,重建或损坏回退必须复现相同结果。索引不能成为第二个鉴权来源,也不能让尚未实际落盘的 child 变得可见。 + +列表查询不添加会话事件或 surface 节点。它读取激活契约保留在 child 日志中、对模型隐藏的描述符,因此经过压缩和未经压缩的 child 必须枚举出相同结果。 + +### `list_agents` 契约 + +`SubagentControlService.listChildren(parent)` 只返回具有有效可继续描述符的持久化直接 child,再叠加进程内 Task 关联。面向模型的 `list_agents` 工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器,并报告两种操作状态: + +- `running`:存在由非终态 Task 支撑的激活,包括启动阶段和 Task 终态发布前的结算阶段; +- `resumable`:存在有效的持久化描述符,且没有关联任何激活。 + +这些值并非 `AgentStatus`。普通 Agent 注册表中没有 Task 关联的条目属于所有权冲突,而不是第三种列表状态。描述符损坏、版本不受支持、parent 不匹配或 child 缺失时,系统会明确失败,而不会将其静默标记为可恢复。 + +第一版只读,不提供 child 删除操作。如果后续产品行为会删除 child 会话,持久化列表会自然移除已删除的 child;任何未来的派生索引都必须移除或 tombstone 同一条目,避免 `list_agents` 保留陈旧状态。 + +## 已考虑的替代方案 + +**将列表查询并入激活 RFC。** 按 id 持久化描述符和从持久化存储恢复无需 parent 到 child 的枚举。保持查询独立,可让 `send_message` 落地时不必同时承担列表状态、扫描性能或删除行为。 + +**枚举 header 中以该 parent 为 parent 的每个持久化会话。** `parentSession` 能证明谱系,却不能证明 child 可继续。列表查询还必须加载并校验描述符。 + +**使用存活的 Agent 注册表作为目录。** 系统会在每个 Task 结束后有意 dispose 对应 run,而且注册表状态会在重启时消失,因此无法支持持久化发现。 + +**持久化 parent 会话目录事件。** 直接 child header 已经提供持久化枚举种子,child 描述符则是重建的权威信息。第二份 parent 日志会重复状态,并造成跨会话顺序和陈旧条目行为,却无助于按 id 恢复。 + +## 验收标准 + +- 枚举使用已实际落盘的会话 header 作为候选,校验 `parentSession`,并且只包含持久化描述符满足持久化 child handle 契约的 child。 +- 列表查询不加载 Agent、不追加会话事件,并从经过压缩和未经压缩的日志返回相同的 child。 +- `list_agents` 只返回有效的直接可继续 child,并报告 `running` 或 `resumable`,不直接透传运行时状态。 +- 恢复 parent 不会激活 child;列表查询读取持久化状态,并且只叠加已经关联的进程内 Task。 +- 已预分配但尚未实际落盘的 child id、一次性 child、损坏描述符、不受支持的描述符版本、parent 不匹配的 child 和陈旧的派生索引条目绝不会被标记为可恢复。 +- 无密钥测试覆盖压缩前后的发现、重启、错误 parent 访问、不受支持的描述符、扫描行为和陈旧索引回退。面向模型的工具具有可运行的快照覆盖。 + +## 风险 + +- 列表查询会扫描一次 header,并且可能加载每个直接 child 的日志;后续的派生索引必须保持相同的鉴权、损坏处理和回退行为。 +- 第一版没有删除操作,因此只要 child 会话仍保留在持久化存储中,它们就会继续出现在列表里,但存活 Agent 资源仍由活跃 Task 数量限制。 +- Task 关联仅存在于一个运行时中。除非部署添加共享租约,否则当另一个进程正在处理某个持久化 child 时,当前进程仍可能将其报告为 `resumable`。 From ceaef2c3d0b4fc8535d1e60bee4d04d976dbb67f Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 10:16:15 +0800 Subject: [PATCH 076/114] Clarify durable subagent catalog and list_agents behavior --- ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...urable-subagent-catalog-and-list-agents.md | 43 ++++++++++++------- ...ble-subagent-catalog-and-list-agents.zh.md | 43 ++++++++++++------- 3 files changed, 58 insertions(+), 32 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index bf9a1ca9ff..fee41ed170 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-durable-subagent-catalog-and-list-agents.md: 8ffc83e26121d7d1b542e549235290c226739a94 -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 5011fc05537ef5e8a3310c5b191226e46c98f40d +2026-07-22-durable-subagent-catalog-and-list-agents.md: 47c99ee6171bbb64416eeb497146a8aa11ea6869 +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: e80e35eddefc050fa49c26cef88df56520eb58f2 diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index 8ffc83e261..47c99ee617 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -16,29 +16,31 @@ Treat parent-to-child enumeration and `list_agents` as one separately reviewed f - find materialized session headers whose `parentSession` identifies the caller's session; - load and validate each candidate's `subagent/descriptor` event without activating the child; -- exclude sessions that are one-shot, corrupt, unsupported, missing, or not direct children; -- overlay the process-local Task association without treating it as durable state. +- union those durable candidates with the parent's process-local Task associations, including active children that have not materialized yet; +- omit one-shot children without a diagnostic, and omit a candidate that becomes unavailable or has a corrupt or unsupported descriptor with a per-child diagnostic; +- expose an inactive child as resumable only when its descriptor is valid and its provider is currently registered with `resume?()`; +- return every resulting child in stable `createdAt` ascending, child-id ascending order. Descriptor format, persistence, by-id lookup, direct-parent authorization, and cold resume remain owned by the activation proposal. Listing consumes those facts but cannot weaken them or invent a second descriptor representation. ### Enumeration decision -The first implementation uses `SessionPersistence.list()` to obtain materialized headers, filters on `SessionHeader.parentSession`, and calls `load()` only for those direct-child candidates to fold their descriptors. The activation contract calls a preallocated id without a durable header and descriptor an **unmaterialized child**: by-id control reports it as unavailable, while persistence listing omits it. A materialized one-shot child lacks the descriptor and is excluded. This path requires no parent-session catalog event or new persistence backend. +The first implementation uses `SessionPersistence.list()` to obtain materialized headers, filters on `SessionHeader.parentSession`, and unions those ids with Task associations owned by the parent. An associated child is resolved from the live association and is never passed to `SessionPersistence.load()`; only inactive direct-child candidates are loaded to fold their descriptors. The activation contract calls a preallocated id without a durable header and descriptor an **unmaterialized child**: by-id control reports an inactive instance as unavailable, but an active association still appears in `list_agents` as `running`. Once that Task becomes terminal, the child remains discoverable only if its durable descriptor validates. A materialized one-shot child lacks the descriptor and is excluded. This path requires no parent-session catalog event or new persistence backend. This O(number of direct children) load path is the correctness baseline. If measured scale later requires an index, that index is derived state: session headers and child descriptors remain authoritative, and rebuilding or corruption fallback must reproduce the same results. An index cannot become a second authorization source or make an unmaterialized child visible. -Listing adds no session event and no surface node. It reads the model-hidden descriptor retained in the child log by the activation contract, so compacted and uncompacted children must enumerate identically. +`SessionPersistence.load()` may durably repair an interrupted child log by appending synthetic closing events. The first version accepts this existing persistence side effect: `listChildren()` creates no Agent and appends no catalog or descriptor event itself, but it is not a storage-read-only operation. It reads the model-hidden descriptor retained in the child log by the activation contract, so compacted and uncompacted children must enumerate identically. ### `list_agents` contract -`SubagentControlService.listChildren(parent)` returns only durable direct children that carry a valid continuable descriptor, then overlays the process-local Task association. The model-facing `list_agents` tool is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control` and reports two operational states: +`SubagentControlService.listChildren(parent)` returns all direct continuable children in the union of durable candidates and active Task associations, plus non-fatal diagnostics for inactive candidates it could not load, validate, or resume. An association records its creation time when the control service allocates the child id; a materialized child uses `SessionHeader.createdAt`. Children are sorted by that `createdAt` ascending, then child id ascending. Diagnostics follow their candidate's same key. The model-facing `list_agents` tool takes no arguments and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`; it renders the complete sorted children and diagnostics together, and reports two operational child states: -- `running`: a non-terminal Task-backed activation exists, including startup and settlement before Task terminal publication; -- `resumable`: a valid durable descriptor exists and no activation is associated. +- `running`: a non-terminal Task-backed activation exists, including startup before materialization and settlement before Task terminal publication; +- `resumable`: no activation is associated, a valid durable descriptor exists, and the named provider is currently registered with `resume?()`. -These values are not `AgentStatus`. A plain Agent registry entry without a Task association is an ownership conflict, not a third list state. Corrupt, unsupported-version, wrong-parent, or missing-child descriptors fail explicitly rather than being silently advertised as resumable. +These values are not `AgentStatus`. A plain Agent registry entry without a Task association is an ownership conflict, not a third list state. Inactive candidates use three diagnostic reasons: `corrupt` for malformed committed data or descriptor content, `unsupported` for an unknown descriptor version, and `unavailable` when the candidate disappears, another child-specific load fails, or its provider is absent or lacks `resume?()`. Each diagnostic identifies the child id and reason without exposing model-hidden descriptor content; the candidate is omitted while healthy siblings remain visible. Failure of the initial `SessionPersistence.list()` operation fails the whole call because no candidate set exists. Headers whose `parentSession` names another parent are filtered before descriptor loading and produce no diagnostic. -The first version is read-only and has no child deletion operation. If later product behavior deletes child sessions, persistence listing naturally drops a deleted child; any future derived index must remove or tombstone the same entry so `list_agents` cannot retain stale state. +The first version has no child deletion operation. If later product behavior deletes child sessions, persistence listing naturally drops a deleted child; any future derived index must remove or tombstone the same entry so `list_agents` cannot retain stale state. ## Alternatives considered @@ -50,17 +52,28 @@ The first version is read-only and has no child deletion operation. If later pro **Persist a parent-session catalog event.** Direct-child headers already provide the durable enumeration seed, and the child descriptor is the reconstruction authority. A second parent log duplicates state and creates cross-session ordering and stale-entry behavior without helping by-id resume. +**Fail the whole listing when one child cannot be loaded.** This makes corruption impossible to overlook, but one damaged sibling removes visibility into every healthy child. Per-child diagnostics preserve discovery while keeping each omission explicit. + +**Add a repair-free descriptor inspection API.** This would make discovery strictly storage-read-only, but expands the persistence seam solely to avoid the interrupted-tail repair that normal session load and eventual resume already require. The first version accepts `load()` semantics and documents the side effect. + +**Paginate or cap the model-facing result.** This bounds one tool result, but makes discovery stateful and can hide older children unless the model follows a cursor. The first version has no arguments and returns the complete stably ordered set; deployments with many durable children accept the corresponding context cost. + ## Acceptance criteria -- Enumeration uses materialized session headers as candidates, validates `parentSession`, and includes only children whose persisted descriptor satisfies the durable child-handle contract. -- Listing loads no Agent, appends no session event, and returns the same children from compacted and uncompacted logs. -- `list_agents` returns only valid direct continuable children and reports `running` or `resumable`, with no pass-through runtime status. +- Durable enumeration uses materialized session headers as candidates, validates `parentSession`, and includes only inactive children whose persisted descriptor satisfies the durable child-handle contract; the final result unions those children with parent-owned active associations. +- Listing loads no Agent and appends no catalog or descriptor event itself, but may trigger `SessionPersistence.load()` interrupted-tail repair for inactive children; an already-associated child is never loaded, and compacted and uncompacted logs return the same children. +- `list_agents` takes no arguments and returns all valid direct continuable children plus per-child diagnostics, sorted by `createdAt` ascending and child id ascending. +- Active Task associations appear as `running` even before durable materialization; after Task terminal, the child appears as `resumable` only when its descriptor validates and its currently registered provider implements `resume?()`. +- `list_agents` reports no pass-through runtime status, uses only `corrupt`, `unsupported`, or `unavailable` diagnostic reasons, and never exposes descriptor contents in a diagnostic. - Parent resume does not activate children; listing reads durable state and overlays only already-associated process-local Tasks. -- A preallocated-but-unmaterialized child id, one-shot child, corrupt descriptor, unsupported descriptor version, wrong-parent child, and stale derived-index entry are never advertised as resumable. -- Keyless tests cover fresh and compacted discovery, restart, wrong-parent access, unsupported descriptors, scan behavior, and stale-index fallback. The model-facing tool has runnable snapshot coverage. +- A preallocated-but-unmaterialized child id, one-shot child, corrupt descriptor, unsupported descriptor version, and stale derived-index entry are never advertised as resumable; non-child headers are filtered before load. +- A corrupt, unsupported, disappeared, or unloadable candidate cannot hide healthy siblings: it is omitted with an id-and-reason diagnostic, while failure of the initial persistence listing fails the whole call. +- Keyless tests cover fresh and compacted discovery, active unmaterialized children, transition from running association to durable resume, provider absence, stable ordering, restart, parent-header prefiltering, isolated child diagnostics, load repair, scan behavior, and stale-index fallback. The model-facing complete-list-plus-diagnostics result has runnable snapshot coverage. ## Risks -- Listing performs one header scan and may load every direct-child log; a later derived index must preserve the same authorization, corruption, and fallback behavior. +- Listing performs one header scan and may load every direct-child log; a later derived index must preserve the same authorization, per-child diagnostic, and fallback behavior. +- Listing may repair interrupted child logs and persist synthetic closing events even though it creates no Agent. This is the existing `SessionPersistence.load()` contract, not a hidden catalog write. - The first version has no deletion operation, so persisted children remain listed for as long as their sessions remain in persistence even though live Agent resources remain bounded by active Tasks. +- The no-argument tool returns every direct continuable child and diagnostic. Stable ordering makes the result deterministic but does not bound model-context growth; pagination or deletion remains a later product decision. - Task associations exist only in one runtime. Another process can report a durable child as `resumable` while work for that child is active elsewhere unless the deployment adds a shared lease. diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md index 5011fc0553..e80e35edde 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -16,29 +16,31 @@ Status: proposed - 查找 `parentSession` 将调用方会话标识为 parent 的已实际落盘会话 header; - 加载并校验每个候选会话的 `subagent/descriptor` 事件,但不激活 child; -- 排除一次性、损坏、不受支持、缺失或并非直接 child 的会话; -- 叠加进程内 Task 关联,但不将该关联视为持久化状态。 +- 将这些持久化候选与 parent 的进程内 Task 关联合并,包括尚未实际落盘的活跃 child; +- 排除一次性 child 且不产生 diagnostic;如果候选在枚举后变得不可用,或其描述符损坏或版本不受支持,则排除该候选并产生对应 child 的 diagnostic; +- 仅当非活跃 child 的描述符有效,且其提供方当前已注册并实现 `resume?()` 时,才将它对外标记为 `resumable`; +- 按 `createdAt` 升序、再按 child id 升序稳定返回所有结果 child。 描述符格式、持久化、按 id 查找、直接 parent 鉴权与从持久化存储恢复仍由激活提案负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。 ### 枚举决策 -第一版使用 `SessionPersistence.list()` 获取已实际落盘的 header,按 `SessionHeader.parentSession` 过滤,并且只对这些直接 child 候选调用 `load()` 来归并其描述符。激活契约将已预分配 id、却没有持久化 header 和描述符的 child 称为 **unmaterialized child**:按 id 的控制操作会报告该 id 不可用,持久化列表则不会列出它。已实际落盘的一次性 child 没有描述符,因此会被排除。这条路径无需 parent 会话目录事件或新的持久化后端。 +第一版使用 `SessionPersistence.list()` 获取已实际落盘的 header,按 `SessionHeader.parentSession` 过滤,再将这些 id 与 parent 拥有的 Task 关联合并。已关联的 child 直接从存活关联中解析,绝不会传给 `SessionPersistence.load()`;只有非活跃的直接 child 候选才会被加载以归并其描述符。激活契约将已预分配 id、却没有持久化 header 和描述符的 child 称为 **unmaterialized child**:按 id 的控制操作会报告非活跃实例不可用,但活跃关联仍会在 `list_agents` 中显示为 `running`。该 Task 进入终态后,只有在持久化描述符通过校验时,这个 child 才会继续可被发现。已实际落盘的一次性 child 没有描述符,因此会被排除。这条路径无需 parent 会话目录事件或新的持久化后端。 这条 O(直接 child 数量)加载路径是正确性基线。如果实测规模日后需要索引,该索引属于派生状态:会话 header 和 child 描述符仍是权威信息,重建或损坏回退必须复现相同结果。索引不能成为第二个鉴权来源,也不能让尚未实际落盘的 child 变得可见。 -列表查询不添加会话事件或 surface 节点。它读取激活契约保留在 child 日志中、对模型隐藏的描述符,因此经过压缩和未经压缩的 child 必须枚举出相同结果。 +`SessionPersistence.load()` 可能通过追加合成的结束事件,持久修复中断的 child 日志。第一版接受这项现有的持久化副作用:`listChildren()` 不会创建 Agent,也不会自行追加目录或描述符事件,但它并非严格的存储只读操作。它读取激活契约保留在 child 日志中、对模型隐藏的描述符,因此经过压缩和未经压缩的 child 必须枚举出相同结果。 ### `list_agents` 契约 -`SubagentControlService.listChildren(parent)` 只返回具有有效可继续描述符的持久化直接 child,再叠加进程内 Task 关联。面向模型的 `list_agents` 工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器,并报告两种操作状态: +`SubagentControlService.listChildren(parent)` 返回持久化候选与活跃 Task 关联并集中的所有直接可继续 child,以及无法加载、校验或恢复非活跃候选时产生的非致命 diagnostic。控制服务分配 child id 时,关联会记录其创建时间;已实际落盘的 child 则使用 `SessionHeader.createdAt`。这些 child 先按该 `createdAt` 升序、再按 child id 升序排序,diagnostic 使用其候选的同一排序键。面向模型的 `list_agents` 工具不接受参数,它是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器;它会一并渲染完整的已排序 child 和 diagnostic,并报告两种 child 操作状态: -- `running`:存在由非终态 Task 支撑的激活,包括启动阶段和 Task 终态发布前的结算阶段; -- `resumable`:存在有效的持久化描述符,且没有关联任何激活。 +- `running`:存在由非终态 Task 支撑的激活,包括实际落盘前的启动阶段和 Task 终态发布前的结算阶段; +- `resumable`:没有关联任何激活,存在有效的持久化描述符,且其指定的提供方当前已注册并实现 `resume?()`。 -这些值并非 `AgentStatus`。普通 Agent 注册表中没有 Task 关联的条目属于所有权冲突,而不是第三种列表状态。描述符损坏、版本不受支持、parent 不匹配或 child 缺失时,系统会明确失败,而不会将其静默标记为可恢复。 +这些值并非 `AgentStatus`。普通 Agent 注册表中没有 Task 关联的条目属于所有权冲突,而不是第三种列表状态。非活跃候选使用三种固定的 diagnostic 原因:格式错误的已提交数据或描述符内容使用 `corrupt`,未知描述符版本使用 `unsupported`,候选消失、出现其他逐 child 加载失败、其提供方缺失或未实现 `resume?()` 时使用 `unavailable`。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。如果初始 `SessionPersistence.list()` 操作失败,因为系统无法获得候选集,整次调用都会失败。`parentSession` 指向其他 parent 的 header 会在加载描述符前被过滤,且不产生 diagnostic。 -第一版只读,不提供 child 删除操作。如果后续产品行为会删除 child 会话,持久化列表会自然移除已删除的 child;任何未来的派生索引都必须移除或 tombstone 同一条目,避免 `list_agents` 保留陈旧状态。 +第一版不提供 child 删除操作。如果后续产品行为会删除 child 会话,持久化列表会自然移除已删除的 child;任何未来的派生索引都必须移除或 tombstone 同一条目,避免 `list_agents` 保留陈旧状态。 ## 已考虑的替代方案 @@ -50,17 +52,28 @@ Status: proposed **持久化 parent 会话目录事件。** 直接 child header 已经提供持久化枚举种子,child 描述符则是重建的权威信息。第二份 parent 日志会重复状态,并造成跨会话顺序和陈旧条目行为,却无助于按 id 恢复。 +**某个 child 无法加载时让整次列表查询失败。** 这种做法不会让损坏问题被忽略,但一个损坏的 sibling 会让每个健康 child 都不再可见。逐 child diagnostic 在保持每次排除明确可见的同时,也保留了发现能力。 + +**添加不会触发修复的描述符检查 API。** 这能使发现严格保持存储只读,但仅为避免中断尾部修复就扩展持久化 seam,而普通会话加载和最终恢复原本就需要执行该修复。第一版接受 `load()` 的语义,并记录这项副作用。 + +**对面向模型的结果分页或设置上限。** 这可以限制一次工具结果的大小,但会使发现成为有状态操作,而且除非模型继续跟随 cursor,否则可能隐藏更早的 child。第一版不接受参数,并返回经稳定排序的完整集合;拥有大量持久化 child 的部署需要接受相应的上下文成本。 + ## 验收标准 -- 枚举使用已实际落盘的会话 header 作为候选,校验 `parentSession`,并且只包含持久化描述符满足持久化 child handle 契约的 child。 -- 列表查询不加载 Agent、不追加会话事件,并从经过压缩和未经压缩的日志返回相同的 child。 -- `list_agents` 只返回有效的直接可继续 child,并报告 `running` 或 `resumable`,不直接透传运行时状态。 +- 持久化枚举使用已实际落盘的会话 header 作为候选,校验 `parentSession`,并且只包含持久化描述符满足持久化 child handle 契约的非活跃 child;最终结果会将这些 child 与 parent 拥有的活跃关联合并。 +- 列表查询不加载 Agent,也不会自行追加目录或描述符事件,但可能对非活跃 child 触发 `SessionPersistence.load()` 的中断尾部修复;已关联的 child 绝不会被加载,且经过压缩和未经压缩的日志会返回相同的 child。 +- `list_agents` 不接受参数,返回所有有效的直接可继续 child 及逐 child diagnostic,并按 `createdAt` 升序、child id 升序排序。 +- 活跃 Task 关联即使尚未实际落盘,也会显示为 `running`;Task 进入终态后,只有在描述符校验通过,且当前注册的提供方实现 `resume?()` 时,child 才会显示为 `resumable`。 +- `list_agents` 不直接透传运行时状态,只使用 `corrupt`、`unsupported` 或 `unavailable` 作为 diagnostic 原因,且绝不在 diagnostic 中暴露描述符内容。 - 恢复 parent 不会激活 child;列表查询读取持久化状态,并且只叠加已经关联的进程内 Task。 -- 已预分配但尚未实际落盘的 child id、一次性 child、损坏描述符、不受支持的描述符版本、parent 不匹配的 child 和陈旧的派生索引条目绝不会被标记为可恢复。 -- 无密钥测试覆盖压缩前后的发现、重启、错误 parent 访问、不受支持的描述符、扫描行为和陈旧索引回退。面向模型的工具具有可运行的快照覆盖。 +- 已预分配但尚未实际落盘的 child id、一次性 child、损坏描述符、不受支持的描述符版本和陈旧的派生索引条目绝不会被标记为可恢复;非 child header 会在加载前被过滤。 +- 损坏、不受支持、已消失或无法加载的候选不能隐藏健康的 sibling:系统会排除该候选,并生成一条含 id 和原因的 diagnostic;只有初始持久化列表查询失败时,整次调用才会失败。 +- 无密钥测试覆盖压缩前后的发现、活跃的尚未实际落盘 child、从正在运行的关联转换为持久化恢复、提供方缺失、稳定排序、重启、parent header 预过滤、单个 child diagnostic 隔离、加载修复、扫描行为和陈旧索引回退。面向模型的完整列表加 diagnostic 结果具有可运行的快照覆盖。 ## 风险 -- 列表查询会扫描一次 header,并且可能加载每个直接 child 的日志;后续的派生索引必须保持相同的鉴权、损坏处理和回退行为。 +- 列表查询会扫描一次 header,并且可能加载每个直接 child 的日志;后续的派生索引必须保持相同的鉴权、逐 child diagnostic 和回退行为。 +- 列表查询可能修复中断的 child 日志并持久化合成的结束事件,即使它不创建 Agent。这是 `SessionPersistence.load()` 的现有契约,而非隐藏的目录写入。 - 第一版没有删除操作,因此只要 child 会话仍保留在持久化存储中,它们就会继续出现在列表里,但存活 Agent 资源仍由活跃 Task 数量限制。 +- 无参数工具会返回每个直接可继续 child 和 diagnostic。稳定排序可使结果确定,但不会限制模型上下文的增长;分页或删除仍是后续的产品决策。 - Task 关联仅存在于一个运行时中。除非部署添加共享租约,否则当另一个进程正在处理某个持久化 child 时,当前进程仍可能将其报告为 `resumable`。 From e63f22f2f1c63d2118c7f6f6eb59dd1d4e70be77 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 10:27:05 +0800 Subject: [PATCH 077/114] temp commit --- ...subagent-catalog-and-list-agents.i18n.yaml | 6 +- ...urable-subagent-catalog-and-list-agents.md | 78 ++++++++++++------- ...ble-subagent-catalog-and-list-agents.zh.md | 78 ++++++++++++------- 3 files changed, 99 insertions(+), 63 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index fee41ed170..73624514e5 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-22-durable-subagent-catalog-and-list-agents.md: 47c99ee6171bbb64416eeb497146a8aa11ea6869 -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: e80e35eddefc050fa49c26cef88df56520eb58f2 +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +2026-07-22-durable-subagent-catalog-and-list-agents.md: b8bca3208541fa8154c3587db4c0520d6a2e3d04 +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 6ee737212da52df21fd30c4fe78d680dbeaf8a5f diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index 47c99ee617..b8bca32085 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -6,39 +6,49 @@ English | [中文](2026-07-22-durable-subagent-catalog-and-list-agents.zh.md) ## Problem -Continuable background subagents expose a stable child id and persist the reconstruction descriptor in that child's session, so `send_message` can resume a known child without any listing operation. `list_agents` has a different requirement: after parent restart, enumerate only that parent's direct continuable children even when the caller no longer knows their ids. The durable child-handle and activation design is owned by [continuable background subagents](2026-07-21-continuable-background-subagents.md); this note owns enumeration and its model-facing query. +Continuable background subagents expose a stable child id and persist the reconstruction descriptor in that child's session, so `send_message` can resume a known child without any listing operation. `list_agents` has a different requirement: after parent restart, enumerate only that parent's direct continuable children even when the caller no longer knows their ids. The durable Session and Activation design is owned by [continuable subagents](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md); this note owns enumeration and its model-facing query. -Enumeration must cross-check immutable session lineage, descriptor validity, and process-local activation state without loading or resuming an Agent merely to display it. It must also define how missing, corrupt, deleted, or unsupported children affect the list and whether repeatedly loading many child logs needs an index. +Enumeration must cross-check immutable session lineage, descriptor validity, and the live-preferred session corpus without loading or resuming an Agent merely to display it. It must also define how missing, corrupt, deleted, or unsupported children affect the list and whether repeatedly loading many child logs needs an index. ## Proposal -Treat parent-to-child enumeration and `list_agents` as one separately reviewed feature built on the durable child-handle contract. `SubagentControlService.listChildren(parent)` must: +Treat parent-to-child enumeration and `list_agents` as one separately reviewed feature built on the durable child-session contract. `SubagentService.listChildren(parent)` must: -- find materialized session headers whose `parentSession` identifies the caller's session; -- load and validate each candidate's `subagent/descriptor` event without activating the child; -- union those durable candidates with the parent's process-local Task associations, including active children that have not materialized yet; +- use `ctx.sessionQuery.traceSession(parent.session.id)` to obtain the caller's direct live-preferred child sessions; +- read and validate each candidate's `subagent/descriptor` event without activating the child; - omit one-shot children without a diagnostic, and omit a candidate that becomes unavailable or has a corrupt or unsupported descriptor with a per-child diagnostic; -- expose an inactive child as resumable only when its descriptor is valid and its provider is currently registered with `resume?()`; +- expose only children whose descriptor carries a durable creation `label`; +- report a live child as `running` and a persisted-only child as `complete`; - return every resulting child in stable `createdAt` ascending, child-id ascending order. -Descriptor format, persistence, by-id lookup, direct-parent authorization, and cold resume remain owned by the activation proposal. Listing consumes those facts but cannot weaken them or invent a second descriptor representation. +Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. This proposal extends the descriptor with a durable `label` and requires listing to diagnose duplicate descriptor events; it cannot weaken the existing facts or invent a second descriptor representation. ### Enumeration decision -The first implementation uses `SessionPersistence.list()` to obtain materialized headers, filters on `SessionHeader.parentSession`, and unions those ids with Task associations owned by the parent. An associated child is resolved from the live association and is never passed to `SessionPersistence.load()`; only inactive direct-child candidates are loaded to fold their descriptors. The activation contract calls a preallocated id without a durable header and descriptor an **unmaterialized child**: by-id control reports an inactive instance as unavailable, but an active association still appears in `list_agents` as `running`. Once that Task becomes terminal, the child remains discoverable only if its durable descriptor validates. A materialized one-shot child lacks the descriptor and is excluded. This path requires no parent-session catalog event or new persistence backend. +The first implementation consumes `ctx.sessionQuery.traceSession(parent.session.id)` and considers only the trace's first-level descendants. Session query already merges `ctx.sessions` with `ctx.sessionPersistence` using live precedence, preserves immutable-header consistency, derives direct-child lineage from `SessionHeader.parentSession`, and sorts siblings by `createdAt` ascending and child id ascending. `listChildren()` does not reproduce that corpus logic or inspect the continuation manager's process-local Activation map. -This O(number of direct children) load path is the correctness baseline. If measured scale later requires an index, that index is derived state: session headers and child descriptors remain authoritative, and rebuilding or corruption fallback must reproduce the same results. An index cannot become a second authorization source or make an unmaterialized child visible. +Corpus construction precedes per-child descriptor inspection. A failure while building the initial trace, including persistence listing failure, a live/persisted header conflict anywhere in the observed corpus, or invalid target lineage, fails the whole `list_agents` call because no trustworthy candidate set exists. Only failures after a successful trace are isolated to one candidate; "corrupt child" in that per-child contract therefore means corrupt loaded event surface or descriptor data, not a corpus-level header conflict. -`SessionPersistence.load()` may durably repair an interrupted child log by appending synthetic closing events. The first version accepts this existing persistence side effect: `listChildren()` creates no Agent and appends no catalog or descriptor event itself, but it is not a storage-read-only operation. It reads the model-hidden descriptor retained in the child log by the activation contract, so compacted and uncompacted children must enumerate identically. +Session lineage is broader than subagent identity: an ordinary `ctx.sessions.fork()` and a one-shot subagent also create direct children. Each candidate must therefore contain exactly one valid `subagent/descriptor` event. The activation contract writes it only during initial creation and cold resume appends no further descriptor; a second event is corruption rather than evidence of another activation. The event distinguishes a continuable background subagent from an ordinary fork or one-shot child; its short creation `label` comes from the delegation's `description`, while its continuation fields remain the reconstruction input for provider-independent cold resume. A candidate without the event is omitted without a diagnostic. + +The published logical record is also the status source: `SessionRecord.live` means `running`, while `live: false, persisted: true` means `complete`. `complete` means that no Activation is live; it encodes neither successful completion nor a permanently closed child, and `send_message` may materialize another Activation. Conversely, `running` says only that the session is live: a live Agent outside the continuation manager's matching Activation still appears as `running`, but `send_message` rejects rather than adopting it. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or status source. Listing is a snapshot that may race publication, disposal, or a later message; `send_message` remains the authoritative delivery-time operation. + +The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren()` method resolves the optional service when called and throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before doing any work when it is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without loading session query; the list tool catches misconfiguration at plugin load, while another direct service consumer receives the same explicit call-time contract. + +This descriptor-read path is the correctness baseline, not a claim that work is linear only in the number of direct children. Let D be the number of direct-child candidates, C the number of persisted sessions scanned by each persistence listing, and L_i the size of candidate i's full log. One corpus trace is followed by two exact reads per candidate. `listChildren()` uses `sessionQuery.listEvents(childId)` to locate the sole descriptor event and `sessionQuery.readEvent({ sessionId: childId, seq })` to read it, and each operation independently loads the logical session. In the persisted-only worst case, every exact read repeats `persistence.list()`, loads the full child log, and clones its events, for O(D × C + Σ L_i) work up to constant factors; a live child instead takes two detached in-memory snapshots of its full log. The persisted path may durably repair an interrupted child log by appending synthetic closing events. The first version accepts the repeated reads as the no-index correctness baseline, but deployments must treat total corpus and child-log size—not only direct-child count—as the capacity constraint. Listing creates no Agent and appends no catalog or descriptor event itself, but it is not a storage-read-only operation. The model-hidden descriptor remains outside the conversation surface and survives compaction, so compacted and uncompacted children must enumerate identically. + +If measured scale later requires an index, that index is derived state: session headers and child descriptors remain authoritative, and rebuilding or corruption fallback must reproduce the same results. An index cannot become a second authorization source or make an unpublished child visible. ### `list_agents` contract -`SubagentControlService.listChildren(parent)` returns all direct continuable children in the union of durable candidates and active Task associations, plus non-fatal diagnostics for inactive candidates it could not load, validate, or resume. An association records its creation time when the control service allocates the child id; a materialized child uses `SessionHeader.createdAt`. Children are sorted by that `createdAt` ascending, then child id ascending. Diagnostics follow their candidate's same key. The model-facing `list_agents` tool takes no arguments and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`; it renders the complete sorted children and diagnostics together, and reports two operational child states: +`SubagentService.listChildren(parent)` returns every direct continuable child found in the session trace plus non-fatal diagnostics for candidates it could not read or validate. Each child carries its session id, descriptor `label`, and one of two snapshot states: -- `running`: a non-terminal Task-backed activation exists, including startup before materialization and settlement before Task terminal publication; -- `resumable`: no activation is associated, a valid durable descriptor exists, and the named provider is currently registered with `resume?()`. +- `running`: the logical session record is live in `ctx.sessions`; +- `complete`: the logical session record exists only in persistence and may be resumed by `send_message`. -These values are not `AgentStatus`. A plain Agent registry entry without a Task association is an ownership conflict, not a third list state. Inactive candidates use three diagnostic reasons: `corrupt` for malformed committed data or descriptor content, `unsupported` for an unknown descriptor version, and `unavailable` when the candidate disappears, another child-specific load fails, or its provider is absent or lacks `resume?()`. Each diagnostic identifies the child id and reason without exposing model-hidden descriptor content; the candidate is omitted while healthy siblings remain visible. Failure of the initial `SessionPersistence.list()` operation fails the whole call because no candidate set exists. Headers whose `parentSession` names another parent are filtered before descriptor loading and produce no diagnostic. +These values are neither `AgentStatus` nor the manager's internal Activation state. Children are sorted by `SessionHeader.createdAt` ascending, then child id ascending; diagnostics follow their candidate's same key. The model-facing `list_agents` tool takes no arguments and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control` that renders the complete sorted children and diagnostics together. + +Diagnostics use three fixed reasons. Malformed event surfaces, conflicting headers discovered during an exact child load, malformed descriptor content, and multiple descriptor events map to `corrupt`. An unknown descriptor version maps to `unsupported`. `SESSION_QUERY_SESSION_NOT_FOUND`, `SESSION_QUERY_EVENT_NOT_FOUND`, and `SESSION_QUERY_PERSISTENCE_FAILED` from a per-child read map to `unavailable`. This phase boundary is intentional: a persistence outage during the initial trace fails the operation, while the same outage beginning during candidate reads may produce one identical `unavailable` diagnostic per affected child; v1 neither coalesces those diagnostics nor promotes them to a global failure. A missing descriptor is instead a one-shot exclusion without a diagnostic. Configuration/window errors and unrecognized failures are not child diagnostics and propagate as operation failures. Each diagnostic identifies the child id and reason without exposing model-hidden descriptor content; the candidate is omitted while healthy siblings remain visible. Sessions outside the trace's direct descendants are never read and produce no diagnostic. The first version has no child deletion operation. If later product behavior deletes child sessions, persistence listing naturally drops a deleted child; any future derived index must remove or tombstone the same entry so `list_agents` cannot retain stale state. @@ -46,9 +56,13 @@ The first version has no child deletion operation. If later product behavior del **Fold listing into the activation RFC.** Descriptor-by-id persistence and cold resume do not require parent-to-child enumeration. Keeping the query separate lets `send_message` land without taking on listing states, scanning performance, or deletion behavior. -**List every persisted session whose header names the parent.** `parentSession` proves lineage but does not prove that the child is continuable. Listing must also load and validate the descriptor. +**Rebuild lineage directly from `SessionPersistence.list()`.** This duplicates session query's live-preferred corpus merge, immutable-header consistency checks, direct-child tracing, and deterministic ordering. Listing uses the existing trusted query service and adds only subagent-specific descriptor validation and rendering. -**Use the live Agent registry as the catalog.** Runs are deliberately disposed after every Task, and registry state disappears on restart. It cannot support durable discovery. +**List every traced child session.** `parentSession` proves lineage but does not prove that the child is a continuable subagent: ordinary session forks and one-shot subagents share that header field. Listing must also read and validate the descriptor. + +**Use the live Agent registry as the catalog.** Activations are deliberately disposed after settlement, and registry state disappears on restart. It cannot support durable discovery. + +**Use the process-local Activation map as a second catalog.** This exposes manager residency but couples a session-discovery query to materialization and settlement, introduces another ordering clock, and makes the same child change candidate source during its lifetime. The first version lists published logical sessions only and treats `SessionRecord.live` as its snapshot status. **Persist a parent-session catalog event.** Direct-child headers already provide the durable enumeration seed, and the child descriptor is the reconstruction authority. A second parent log duplicates state and creates cross-session ordering and stale-entry behavior without helping by-id resume. @@ -60,20 +74,24 @@ The first version has no child deletion operation. If later product behavior del ## Acceptance criteria -- Durable enumeration uses materialized session headers as candidates, validates `parentSession`, and includes only inactive children whose persisted descriptor satisfies the durable child-handle contract; the final result unions those children with parent-owned active associations. -- Listing loads no Agent and appends no catalog or descriptor event itself, but may trigger `SessionPersistence.load()` interrupted-tail repair for inactive children; an already-associated child is never loaded, and compacted and uncompacted logs return the same children. -- `list_agents` takes no arguments and returns all valid direct continuable children plus per-child diagnostics, sorted by `createdAt` ascending and child id ascending. -- Active Task associations appear as `running` even before durable materialization; after Task terminal, the child appears as `resumable` only when its descriptor validates and its currently registered provider implements `resume?()`. -- `list_agents` reports no pass-through runtime status, uses only `corrupt`, `unsupported`, or `unavailable` diagnostic reasons, and never exposes descriptor contents in a diagnostic. -- Parent resume does not activate children; listing reads durable state and overlays only already-associated process-local Tasks. -- A preallocated-but-unmaterialized child id, one-shot child, corrupt descriptor, unsupported descriptor version, and stale derived-index entry are never advertised as resumable; non-child headers are filtered before load. -- A corrupt, unsupported, disappeared, or unloadable candidate cannot hide healthy siblings: it is omitted with an id-and-reason diagnostic, while failure of the initial persistence listing fails the whole call. -- Keyless tests cover fresh and compacted discovery, active unmaterialized children, transition from running association to durable resume, provider absence, stable ordering, restart, parent-header prefiltering, isolated child diagnostics, load repair, scan behavior, and stale-index fallback. The model-facing complete-list-plus-diagnostics result has runnable snapshot coverage. +- Enumeration uses `ctx.sessionQuery.traceSession(parent.session.id)`, considers only direct descendants, and does not duplicate corpus merging, lineage reconstruction, or sibling ordering. +- Listing loads no Agent, materializes no Activation, and appends no catalog or descriptor event itself. After the initial trace it performs two independent exact session-query reads per candidate; persisted reads may trigger interrupted-tail repair, and compacted and uncompacted logs return the same children. +- A valid descriptor includes the delegation's durable `label`; ordinary session forks and one-shot children lack that descriptor and are omitted without a diagnostic. Provider registration does not affect discovery or provider-independent cold resume. +- Initial creation writes exactly one descriptor event, cold resume writes none, and a candidate with more than one descriptor event is diagnosed as `corrupt`. +- `list_agents` takes no arguments and returns every valid direct continuable child with its id, label, and `running` or `complete` snapshot state, plus per-child diagnostics, sorted by `createdAt` ascending and child id ascending. +- A live logical session is `running`; a persisted-only logical session is `complete` and remains eligible for a later `send_message`. The result does not consult the process-local Activation map. +- Parent resume does not activate children. A child is absent until its session is published, and listing may race publication, disposal, or later delivery without weakening `send_message`'s execution-time checks. +- `list_agents` uses only `corrupt`, `unsupported`, or `unavailable` diagnostic reasons and never exposes descriptor contents in a diagnostic. +- After a successful initial trace, a corrupt, unsupported, disappeared, or unreadable descriptor candidate cannot hide healthy siblings: it is omitted with an id-and-reason diagnostic. Corpus-level persistence, header-consistency, or lineage failure during that initial trace fails the whole call. +- Per-child session-query failures map deterministically: invalid surfaces and exact-load source conflicts are `corrupt`; missing sessions or events and persistence failures are `unavailable`; unknown descriptor versions are `unsupported`; and missing descriptors are omitted as one-shot children. +- The list tool requires `sessionQuery` at plugin load; a direct `listChildren()` call without it fails before enumeration with `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`, while by-id `send_message` remains usable without that service. +- Keyless tests cover fresh and compacted discovery, ordinary fork and one-shot exclusion, live-to-complete transition, unmanaged-live-session snapshots, provider-independent discovery, durable `label` values, stable ordering, restart, direct-child tracing, duplicate descriptor rejection, isolated child diagnostics, phase-dependent persistence failure, load repair, snapshot races, and scan behavior. The model-facing complete-list-plus-diagnostics result has runnable snapshot coverage. ## Risks -- Listing performs one header scan and may load every direct-child log; a later derived index must preserve the same authorization, per-child diagnostic, and fallback behavior. -- Listing may repair interrupted child logs and persist synthetic closing events even though it creates no Agent. This is the existing `SessionPersistence.load()` contract, not a hidden catalog write. -- The first version has no deletion operation, so persisted children remain listed for as long as their sessions remain in persistence even though live Agent resources remain bounded by active Tasks. +- Session tracing observes the complete logical corpus, then descriptor validation reads each direct-child log twice. In the persisted-only worst case, work is O(D × C + Σ L_i), not merely O(D), because each exact read rescans persistence and loads and clones the full candidate log. A later derived index must preserve the same authorization, per-child diagnostic, and fallback behavior. +- Corpus construction is an all-or-nothing trust boundary: one live/persisted header conflict can fail the initial trace and hide otherwise healthy siblings. Per-child isolation begins only after that trace succeeds. +- Session-query reads may repair interrupted child logs and persist synthetic closing events even though listing creates no Agent. This is the existing persistence-load contract, not a hidden catalog write. +- The first version has no deletion operation, so persisted children remain listed for as long as their sessions remain in persistence even though live Agent resources remain bounded by resident Activations. - The no-argument tool returns every direct continuable child and diagnostic. Stable ordering makes the result deterministic but does not bound model-context growth; pagination or deletion remains a later product decision. -- Task associations exist only in one runtime. Another process can report a durable child as `resumable` while work for that child is active elsewhere unless the deployment adds a shared lease. +- `running` and `complete` are process-local corpus snapshots, not delivery promises. Another process may activate a persisted child while this process reports it as `complete`; cross-process accuracy requires a shared lease. diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md index e80e35edde..6ee737212d 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -6,39 +6,49 @@ Status: proposed ## 问题 -可继续的后台 subagent 会公开稳定的 child id,并将重建描述符持久化在该 child 的会话中,因此 `send_message` 无需任何列表查询操作即可恢复已知 child。`list_agents` 的要求不同:parent 重启后,即使调用方不再知道各 child id,也要只枚举该 parent 的直接可继续 child。[可继续的后台 subagent](2026-07-21-continuable-background-subagents.md)负责持久化 child handle 与激活设计;本记录负责枚举及其面向模型的查询。 +可继续的后台 subagent 会公开稳定的 child id,并将重建描述符持久化在该 child 的会话中,因此 `send_message` 无需任何列表查询操作即可恢复已知 child。`list_agents` 的要求不同:parent 重启后,即使调用方不再知道各 child id,也要只枚举该 parent 的直接可继续 child。[可继续 subagent](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md)负责持久化 Session 与 Activation 设计;本记录负责枚举及其面向模型的查询。 -枚举必须交叉核对不可变的会话谱系、描述符有效性与进程内激活状态,而不能仅为展示就加载或恢复 Agent。它还必须定义缺失、损坏、已删除或不受支持的 child 如何影响列表,以及反复加载大量 child 日志是否需要索引。 +枚举必须交叉核对不可变的会话谱系、描述符有效性与实时优先的会话语料,而不能仅为展示就加载或恢复 Agent。它还必须定义缺失、损坏、已删除或不受支持的 child 如何影响列表,以及反复加载大量 child 日志是否需要索引。 ## 提案 -将 parent 到 child 的枚举与 `list_agents` 作为一个基于持久化 child handle 契约、单独评审的功能。`SubagentControlService.listChildren(parent)` 必须: +将 parent 到 child 的枚举与 `list_agents` 作为一个基于持久化 child Session 契约、单独评审的功能。`SubagentService.listChildren(parent)` 必须: -- 查找 `parentSession` 将调用方会话标识为 parent 的已实际落盘会话 header; -- 加载并校验每个候选会话的 `subagent/descriptor` 事件,但不激活 child; -- 将这些持久化候选与 parent 的进程内 Task 关联合并,包括尚未实际落盘的活跃 child; +- 使用 `ctx.sessionQuery.traceSession(parent.session.id)` 获取调用方直接且实时优先的 child 会话; +- 读取并校验每个候选会话的 `subagent/descriptor` 事件,但不激活 child; - 排除一次性 child 且不产生 diagnostic;如果候选在枚举后变得不可用,或其描述符损坏或版本不受支持,则排除该候选并产生对应 child 的 diagnostic; -- 仅当非活跃 child 的描述符有效,且其提供方当前已注册并实现 `resume?()` 时,才将它对外标记为 `resumable`; +- 只公开描述符带有持久化创建 `label` 的 child; +- 将存活 child 报告为 `running`,只存在于持久化存储中的 child 报告为 `complete`; - 按 `createdAt` 升序、再按 child id 升序稳定返回所有结果 child。 -描述符格式、持久化、按 id 查找、直接 parent 鉴权与从持久化存储恢复仍由激活提案负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。 +描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 契约负责。本提案会为描述符增加持久化 `label`,并要求列表查询诊断重复的描述符事件;它不能削弱现有事实,也不能发明第二种描述符表示。 ### 枚举决策 -第一版使用 `SessionPersistence.list()` 获取已实际落盘的 header,按 `SessionHeader.parentSession` 过滤,再将这些 id 与 parent 拥有的 Task 关联合并。已关联的 child 直接从存活关联中解析,绝不会传给 `SessionPersistence.load()`;只有非活跃的直接 child 候选才会被加载以归并其描述符。激活契约将已预分配 id、却没有持久化 header 和描述符的 child 称为 **unmaterialized child**:按 id 的控制操作会报告非活跃实例不可用,但活跃关联仍会在 `list_agents` 中显示为 `running`。该 Task 进入终态后,只有在持久化描述符通过校验时,这个 child 才会继续可被发现。已实际落盘的一次性 child 没有描述符,因此会被排除。这条路径无需 parent 会话目录事件或新的持久化后端。 +第一版消费 `ctx.sessionQuery.traceSession(parent.session.id)`,并且只考虑追踪结果的第一层后代。会话查询已经使用实时优先规则合并 `ctx.sessions` 与 `ctx.sessionPersistence`,保持不可变 header 一致性,根据 `SessionHeader.parentSession` 推导直接 child 谱系,并按 `createdAt` 升序、child id 升序排列 sibling。`listChildren()` 不会重复实现这套语料逻辑,也不会检查继续执行管理器的进程内 Activation map。 -这条 O(直接 child 数量)加载路径是正确性基线。如果实测规模日后需要索引,该索引属于派生状态:会话 header 和 child 描述符仍是权威信息,重建或损坏回退必须复现相同结果。索引不能成为第二个鉴权来源,也不能让尚未实际落盘的 child 变得可见。 +语料构建先于逐 child 描述符检查。构建初始追踪时如果发生持久化列表查询失败、所观测语料中任意位置的存活/持久化 header 冲突或目标谱系无效,整个 `list_agents` 调用都会失败,因为此时不存在可信的候选集。只有初始追踪成功后的失败才会被隔离到单个候选;因此,这项逐 child 契约中的“损坏 child”是指已加载的事件 surface 或描述符数据损坏,而不是语料级 header 冲突。 -`SessionPersistence.load()` 可能通过追加合成的结束事件,持久修复中断的 child 日志。第一版接受这项现有的持久化副作用:`listChildren()` 不会创建 Agent,也不会自行追加目录或描述符事件,但它并非严格的存储只读操作。它读取激活契约保留在 child 日志中、对模型隐藏的描述符,因此经过压缩和未经压缩的 child 必须枚举出相同结果。 +会话谱系涵盖的范围比 subagent 身份更广:普通 `ctx.sessions.fork()` 和一次性 subagent 也会创建直接 child。因此,每个候选都必须恰好包含一个有效的 `subagent/descriptor` 事件。激活契约只在初始创建期间写入该事件,从持久化存储恢复时不会追加其他描述符;第二个事件属于损坏,而不是另一次激活的证据。该事件用于区分可继续的后台 subagent 与普通 fork 或一次性 child;其简短创建 `label` 来自委派的 `description`,其余继续执行字段仍是不依赖提供方的冷恢复所使用的重建输入。缺少该事件的候选会被排除,且不产生 diagnostic。 + +已发布的逻辑记录同时也是状态来源:`SessionRecord.live` 表示 `running`,而 `live: false, persisted: true` 表示 `complete`。`complete` 表示当前没有存活的 Activation,既不表示执行成功,也不表示 child 已永久关闭;`send_message` 仍可物化另一次 Activation。反过来,`running` 只表示会话存活:位于继续执行管理器对应 Activation 之外的存活 Agent 仍会显示为 `running`,但 `send_message` 会拒绝,而不会接管它。child 会话发布前不可见,也不会添加进程内 Activation 条目作为第二个候选来源或状态来源。列表查询是一份快照,可能与发布、dispose 或后续消息发生竞态;`send_message` 仍是消息送达时的权威操作。 + +subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren()` 方法在调用时解析这个可选服务;如果服务缺失,该方法会在执行任何工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在不加载会话查询的情况下使用 `send_message`;列表工具会在插件加载时捕获配置错误,而其他直接服务消费方会收到同一项明确的调用时契约。 + +这条描述符读取路径是正确性基线,并不声称工作量只与直接 child 数量呈线性关系。令 D 为直接 child 候选数量,C 为每次持久化列表查询所扫描的持久化会话数量,L_i 为候选 i 的完整日志大小。一次语料追踪后,每个候选会执行两次精确读取。`listChildren()` 使用 `sessionQuery.listEvents(childId)` 定位唯一的描述符事件,并使用 `sessionQuery.readEvent({ sessionId: childId, seq })` 读取该事件;每项操作都会独立加载逻辑会话。对于只存在于持久化存储中的最坏情况,每次精确读取都会重复执行 `persistence.list()`、加载完整 child 日志并克隆其中的事件,因此忽略常数因子后的工作量为 O(D × C + Σ L_i);存活 child 则会对其完整日志取得两份分离的内存快照。持久化路径可能通过追加合成的结束事件,持久修复中断的 child 日志。第一版接受这些重复读取,将其作为无索引的正确性基线,但部署必须将语料总量和 child 日志大小,而不仅是直接 child 数量,视为容量约束。列表查询不会创建 Agent,也不会自行追加目录或描述符事件,但它并非严格的存储只读操作。对模型隐藏的描述符始终位于对话 surface 之外,并且会在压缩后保留,因此经过压缩和未经压缩的 child 必须枚举出相同结果。 + +如果实测规模日后需要索引,该索引属于派生状态:会话 header 和 child 描述符仍是权威信息,重建或损坏回退必须复现相同结果。索引不能成为第二个鉴权来源,也不能让尚未发布的 child 变得可见。 ### `list_agents` 契约 -`SubagentControlService.listChildren(parent)` 返回持久化候选与活跃 Task 关联并集中的所有直接可继续 child,以及无法加载、校验或恢复非活跃候选时产生的非致命 diagnostic。控制服务分配 child id 时,关联会记录其创建时间;已实际落盘的 child 则使用 `SessionHeader.createdAt`。这些 child 先按该 `createdAt` 升序、再按 child id 升序排序,diagnostic 使用其候选的同一排序键。面向模型的 `list_agents` 工具不接受参数,它是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器;它会一并渲染完整的已排序 child 和 diagnostic,并报告两种 child 操作状态: +`SubagentService.listChildren(parent)` 返回会话追踪中找到的每个直接可继续 child,以及无法读取或校验候选时产生的非致命 diagnostic。每个 child 都携带自己的 session id、描述符 `label`,以及两种快照状态之一: -- `running`:存在由非终态 Task 支撑的激活,包括实际落盘前的启动阶段和 Task 终态发布前的结算阶段; -- `resumable`:没有关联任何激活,存在有效的持久化描述符,且其指定的提供方当前已注册并实现 `resume?()`。 +- `running`:逻辑会话记录在 `ctx.sessions` 中存活; +- `complete`:逻辑会话记录只存在于持久化存储中,并且可以由 `send_message` 恢复。 -这些值并非 `AgentStatus`。普通 Agent 注册表中没有 Task 关联的条目属于所有权冲突,而不是第三种列表状态。非活跃候选使用三种固定的 diagnostic 原因:格式错误的已提交数据或描述符内容使用 `corrupt`,未知描述符版本使用 `unsupported`,候选消失、出现其他逐 child 加载失败、其提供方缺失或未实现 `resume?()` 时使用 `unavailable`。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。如果初始 `SessionPersistence.list()` 操作失败,因为系统无法获得候选集,整次调用都会失败。`parentSession` 指向其他 parent 的 header 会在加载描述符前被过滤,且不产生 diagnostic。 +这些值既不是 `AgentStatus`,也不是管理器内部的 Activation 状态。child 按 `SessionHeader.createdAt` 升序、再按 child id 升序排序;diagnostic 使用其候选的同一排序键。面向模型的 `list_agents` 工具不接受参数,它是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器,会一并渲染完整的已排序 child 和 diagnostic。 + +diagnostic 使用三种固定原因。格式错误的事件 surface、精确加载 child 时发现的 header 冲突、格式错误的描述符内容和多个描述符事件映射为 `corrupt`。未知描述符版本映射为 `unsupported`。逐 child 读取产生的 `SESSION_QUERY_SESSION_NOT_FOUND`、`SESSION_QUERY_EVENT_NOT_FOUND` 和 `SESSION_QUERY_PERSISTENCE_FAILED` 映射为 `unavailable`。这项阶段边界是有意为之:初始追踪期间发生持久化故障会让操作失败,而同一故障如果始于候选读取期间,可能会让每个受影响的 child 分别产生一条相同的 `unavailable` diagnostic;第一版既不合并这些 diagnostic,也不会把它们提升为全局失败。缺少描述符则视为一次性 child,直接排除且不产生 diagnostic。配置错误、窗口错误和未识别的失败不属于 child diagnostic,会作为操作失败继续向上传播。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。系统绝不会读取不属于追踪结果直接后代的会话,也不会为它们产生 diagnostic。 第一版不提供 child 删除操作。如果后续产品行为会删除 child 会话,持久化列表会自然移除已删除的 child;任何未来的派生索引都必须移除或 tombstone 同一条目,避免 `list_agents` 保留陈旧状态。 @@ -46,9 +56,13 @@ Status: proposed **将列表查询并入激活 RFC。** 按 id 持久化描述符和从持久化存储恢复无需 parent 到 child 的枚举。保持查询独立,可让 `send_message` 落地时不必同时承担列表状态、扫描性能或删除行为。 -**枚举 header 中以该 parent 为 parent 的每个持久化会话。** `parentSession` 能证明谱系,却不能证明 child 可继续。列表查询还必须加载并校验描述符。 +**直接通过 `SessionPersistence.list()` 重建谱系。** 这种做法会重复实现会话查询中的实时优先语料合并、不可变 header 一致性检查、直接 child 追踪和确定性排序。列表查询应使用现有可信查询服务,只增加 subagent 特有的描述符校验与渲染。 -**使用存活的 Agent 注册表作为目录。** 系统会在每个 Task 结束后有意 dispose 对应 run,而且注册表状态会在重启时消失,因此无法支持持久化发现。 +**列出每个已追踪的 child 会话。** `parentSession` 能证明谱系,却不能证明 child 是可继续的 subagent:普通会话 fork 和一次性 subagent 也使用这个 header 字段。列表查询还必须读取并校验描述符。 + +**使用存活的 Agent 注册表作为目录。** 系统会在 Activation 结算后有意 dispose 它,而且注册表状态会在重启时消失,因此无法支持持久化发现。 + +**使用进程内 Activation map 作为第二个目录。** 这种做法能公开管理器驻留状态,却会让会话发现查询与物化及结算耦合,引入另一套排序时钟,并让同一个 child 在其生命周期内改变候选来源。第一版只列出已经发布的逻辑会话,并将 `SessionRecord.live` 视为其快照状态。 **持久化 parent 会话目录事件。** 直接 child header 已经提供持久化枚举种子,child 描述符则是重建的权威信息。第二份 parent 日志会重复状态,并造成跨会话顺序和陈旧条目行为,却无助于按 id 恢复。 @@ -60,20 +74,24 @@ Status: proposed ## 验收标准 -- 持久化枚举使用已实际落盘的会话 header 作为候选,校验 `parentSession`,并且只包含持久化描述符满足持久化 child handle 契约的非活跃 child;最终结果会将这些 child 与 parent 拥有的活跃关联合并。 -- 列表查询不加载 Agent,也不会自行追加目录或描述符事件,但可能对非活跃 child 触发 `SessionPersistence.load()` 的中断尾部修复;已关联的 child 绝不会被加载,且经过压缩和未经压缩的日志会返回相同的 child。 -- `list_agents` 不接受参数,返回所有有效的直接可继续 child 及逐 child diagnostic,并按 `createdAt` 升序、child id 升序排序。 -- 活跃 Task 关联即使尚未实际落盘,也会显示为 `running`;Task 进入终态后,只有在描述符校验通过,且当前注册的提供方实现 `resume?()` 时,child 才会显示为 `resumable`。 -- `list_agents` 不直接透传运行时状态,只使用 `corrupt`、`unsupported` 或 `unavailable` 作为 diagnostic 原因,且绝不在 diagnostic 中暴露描述符内容。 -- 恢复 parent 不会激活 child;列表查询读取持久化状态,并且只叠加已经关联的进程内 Task。 -- 已预分配但尚未实际落盘的 child id、一次性 child、损坏描述符、不受支持的描述符版本和陈旧的派生索引条目绝不会被标记为可恢复;非 child header 会在加载前被过滤。 -- 损坏、不受支持、已消失或无法加载的候选不能隐藏健康的 sibling:系统会排除该候选,并生成一条含 id 和原因的 diagnostic;只有初始持久化列表查询失败时,整次调用才会失败。 -- 无密钥测试覆盖压缩前后的发现、活跃的尚未实际落盘 child、从正在运行的关联转换为持久化恢复、提供方缺失、稳定排序、重启、parent header 预过滤、单个 child diagnostic 隔离、加载修复、扫描行为和陈旧索引回退。面向模型的完整列表加 diagnostic 结果具有可运行的快照覆盖。 +- 枚举使用 `ctx.sessionQuery.traceSession(parent.session.id)`,只考虑直接后代,并且不重复实现语料合并、谱系重建或 sibling 排序。 +- 列表查询不会加载 Agent、物化 Activation,也不会自行追加目录或描述符事件。初始追踪完成后,它会对每个候选执行两次相互独立的会话查询精确读取;持久化读取可能触发中断尾部修复,且经过压缩和未经压缩的日志会返回相同的 child。 +- 有效描述符包含委派的持久化 `label`;普通会话 fork 和一次性 child 缺少该描述符,因此会被排除且不产生 diagnostic。提供方注册状态不影响发现,也不影响不依赖提供方的冷恢复。 +- 初始创建恰好写入一个描述符事件,从持久化存储恢复时不写入任何描述符;如果候选包含多个描述符事件,则将其诊断为 `corrupt`。 +- `list_agents` 不接受参数,返回每个有效的直接可继续 child 及其 id、label 和 `running` 或 `complete` 快照状态,并返回逐 child diagnostic;结果按 `createdAt` 升序、child id 升序排序。 +- 存活的逻辑会话为 `running`;只存在于持久化存储中的逻辑会话为 `complete`,并且仍可在之后通过 `send_message` 恢复。结果不查询进程内 Activation map。 +- 恢复 parent 不会激活 child。child 会话发布前不会出现,列表查询可能与发布、dispose 或后续消息送达发生竞态,但不会削弱 `send_message` 在执行时进行的检查。 +- `list_agents` 只使用 `corrupt`、`unsupported` 或 `unavailable` 作为 diagnostic 原因,且绝不在 diagnostic 中暴露描述符内容。 +- 初始追踪成功后,描述符损坏、不受支持、已消失或无法读取的候选不能隐藏健康的 sibling:系统会排除该候选,并生成一条含 id 和原因的 diagnostic。初始追踪期间发生的语料级持久化、header 一致性或谱系失败会让整次调用失败。 +- 逐 child 会话查询失败采用固定映射:无效 surface 和精确加载时的来源冲突映射为 `corrupt`;会话或事件缺失以及持久化失败映射为 `unavailable`;未知描述符版本映射为 `unsupported`;缺少描述符则作为一次性 child 排除。 +- 列表工具在插件加载时要求 `sessionQuery`;直接调用 `listChildren()` 时如果缺少该服务,则会在枚举前以 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 失败,而按 id 的 `send_message` 在没有该服务时仍可使用。 +- 无密钥测试覆盖压缩前后的发现、排除普通 fork 和一次性 child、从存活到 complete 的转换、未受管理的存活会话快照、不依赖提供方的发现、持久化 `label` 值、稳定排序、重启、直接 child 追踪、重复描述符拒绝、单个 child diagnostic 隔离、依阶段而异的持久化失败、加载修复、快照竞态和扫描行为。面向模型的完整列表加 diagnostic 结果具有可运行的快照覆盖。 ## 风险 -- 列表查询会扫描一次 header,并且可能加载每个直接 child 的日志;后续的派生索引必须保持相同的鉴权、逐 child diagnostic 和回退行为。 -- 列表查询可能修复中断的 child 日志并持久化合成的结束事件,即使它不创建 Agent。这是 `SessionPersistence.load()` 的现有契约,而非隐藏的目录写入。 -- 第一版没有删除操作,因此只要 child 会话仍保留在持久化存储中,它们就会继续出现在列表里,但存活 Agent 资源仍由活跃 Task 数量限制。 +- 会话追踪会观察完整的逻辑语料,随后描述符校验会读取每个直接 child 的日志两次。对于只存在于持久化存储中的最坏情况,工作量为 O(D × C + Σ L_i),而不只是 O(D),因为每次精确读取都会重新扫描持久化存储,并加载和克隆候选的完整日志。后续的派生索引必须保持相同的鉴权、逐 child diagnostic 和回退行为。 +- 语料构建是一个全有或全无的信任边界:一处存活/持久化 header 冲突就可能导致初始追踪失败,并隐藏原本健康的 sibling。只有初始追踪成功后,逐 child 隔离才会生效。 +- 会话查询读取可能修复中断的 child 日志并持久化合成的结束事件,即使列表查询不创建 Agent。这是现有的持久化加载契约,而非隐藏的目录写入。 +- 第一版没有删除操作,因此只要 child 会话仍保留在持久化存储中,它们就会继续出现在列表里,但存活 Agent 资源仍由驻留 Activation 数量限制。 - 无参数工具会返回每个直接可继续 child 和 diagnostic。稳定排序可使结果确定,但不会限制模型上下文的增长;分页或删除仍是后续的产品决策。 -- Task 关联仅存在于一个运行时中。除非部署添加共享租约,否则当另一个进程正在处理某个持久化 child 时,当前进程仍可能将其报告为 `resumable`。 +- `running` 和 `complete` 是进程内语料快照,而非消息送达承诺。另一个进程可能在当前进程将某个持久化 child 报告为 `complete` 时激活它;跨进程准确性需要共享租约。 From c7acc8fc6cc2bdb27d9b6927b698d3362021a822 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 16:09:20 +0800 Subject: [PATCH 078/114] docs: finalize list_agents RFC contract --- ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...urable-subagent-catalog-and-list-agents.md | 59 +++++++++++-------- ...ble-subagent-catalog-and-list-agents.zh.md | 59 +++++++++++-------- 3 files changed, 72 insertions(+), 50 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index 73624514e5..7b16ca1911 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.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/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md -2026-07-22-durable-subagent-catalog-and-list-agents.md: b8bca3208541fa8154c3587db4c0520d6a2e3d04 -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 6ee737212da52df21fd30c4fe78d680dbeaf8a5f +2026-07-22-durable-subagent-catalog-and-list-agents.md: 23d5b3924a20ae84132048b26e12779a98a6f2bb +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: cac14271515d031a39bf6690f199c97be7bb9fa9 diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index b8bca32085..23d5b3924a 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -12,12 +12,12 @@ Enumeration must cross-check immutable session lineage, descriptor validity, and ## Proposal -Treat parent-to-child enumeration and `list_agents` as one separately reviewed feature built on the durable child-session contract. `SubagentService.listChildren(parent)` must: +Treat parent-to-child enumeration and `list_agents` as one separately reviewed feature built on the durable child-session contract. `SubagentService.listChildren(parentSessionId: SessionId)` must: -- use `ctx.sessionQuery.traceSession(parent.session.id)` to obtain the caller's direct live-preferred child sessions; +- use `ctx.sessionQuery.traceSession(parentSessionId)` to obtain the parent's direct live-preferred child sessions; - read and validate each candidate's `subagent/descriptor` event without activating the child; -- omit one-shot children without a diagnostic, and omit a candidate that becomes unavailable or has a corrupt or unsupported descriptor with a per-child diagnostic; -- expose only children whose descriptor carries a durable creation `label`; +- silently omit candidates without a descriptor, and omit a candidate that becomes unavailable or has a corrupt or unsupported descriptor with a per-child diagnostic; +- expose every child whose supported valid descriptor carries a durable creation `label`, regardless of whether its provider is currently registered; - report a live child as `running` and a persisted-only child as `complete`; - return every resulting child in stable `createdAt` ascending, child-id ascending order. @@ -25,30 +25,34 @@ Descriptor persistence, by-id lookup, direct-parent authorization, and provider- ### Enumeration decision -The first implementation consumes `ctx.sessionQuery.traceSession(parent.session.id)` and considers only the trace's first-level descendants. Session query already merges `ctx.sessions` with `ctx.sessionPersistence` using live precedence, preserves immutable-header consistency, derives direct-child lineage from `SessionHeader.parentSession`, and sorts siblings by `createdAt` ascending and child id ascending. `listChildren()` does not reproduce that corpus logic or inspect the continuation manager's process-local Activation map. +The first implementation consumes `ctx.sessionQuery.traceSession(parentSessionId)` and considers only the trace's first-level descendants. The target may be live or persisted; tracing the logical corpus does not load or resume an Agent. Session query already merges `ctx.sessions` with `ctx.sessionPersistence` using live precedence, preserves immutable-header consistency, derives direct-child lineage from `SessionHeader.parentSession`, and sorts siblings by `createdAt` ascending and child id ascending. `listChildren()` does not reproduce that corpus logic or inspect the continuation manager's process-local Activation map. Corpus construction precedes per-child descriptor inspection. A failure while building the initial trace, including persistence listing failure, a live/persisted header conflict anywhere in the observed corpus, or invalid target lineage, fails the whole `list_agents` call because no trustworthy candidate set exists. Only failures after a successful trace are isolated to one candidate; "corrupt child" in that per-child contract therefore means corrupt loaded event surface or descriptor data, not a corpus-level header conflict. -Session lineage is broader than subagent identity: an ordinary `ctx.sessions.fork()` and a one-shot subagent also create direct children. Each candidate must therefore contain exactly one valid `subagent/descriptor` event. The activation contract writes it only during initial creation and cold resume appends no further descriptor; a second event is corruption rather than evidence of another activation. The event distinguishes a continuable background subagent from an ordinary fork or one-shot child; its short creation `label` comes from the delegation's `description`, while its continuation fields remain the reconstruction input for provider-independent cold resume. A candidate without the event is omitted without a diagnostic. +Session lineage is broader than subagent identity: an ordinary `ctx.sessions.fork()` and a one-shot subagent also create direct children. The session header gains no `kind` discriminator; each candidate must instead contain exactly one valid `subagent/descriptor` event. The Activation contract writes it only during initial creation and cold resume appends no further descriptor; a second event is corruption rather than evidence of another Activation. The event is the sole evidence that a traced child is a continuable background subagent; its short creation `label` comes from the delegation's `description`, while its continuation fields remain the reconstruction input for provider-independent cold resume. A candidate without the event is an ordinary fork, one-shot child, or another non-continuable session and is omitted without a diagnostic. -The published logical record is also the status source: `SessionRecord.live` means `running`, while `live: false, persisted: true` means `complete`. `complete` means that no Activation is live; it encodes neither successful completion nor a permanently closed child, and `send_message` may materialize another Activation. Conversely, `running` says only that the session is live: a live Agent outside the continuation manager's matching Activation still appears as `running`, but `send_message` rejects rather than adopting it. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or status source. Listing is a snapshot that may race publication, disposal, or a later message; `send_message` remains the authoritative delivery-time operation. +The published logical record is also the status source: `SessionRecord.live` means `running`, while `live: false, persisted: true` means `complete`. This status comes directly from the trace and causes no additional child-log load. `complete` means that no Activation is live; it encodes neither successful completion nor a permanently closed child, and `send_message` may materialize another Activation. Conversely, `running` says only that the session is live: a live Agent outside the continuation manager's matching Activation still appears as `running`, but `send_message` rejects rather than adopting it. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or status source. Listing is a snapshot that may race publication, disposal, or a later message; `send_message` remains the authoritative delivery-time operation. -The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren()` method resolves the optional service when called and throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before doing any work when it is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without loading session query; the list tool catches misconfiguration at plugin load, while another direct service consumer receives the same explicit call-time contract. +The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service when called and throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before doing any work when it is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without loading session query; the list tool catches misconfiguration at plugin load, while another direct service consumer receives the same explicit call-time contract. -This descriptor-read path is the correctness baseline, not a claim that work is linear only in the number of direct children. Let D be the number of direct-child candidates, C the number of persisted sessions scanned by each persistence listing, and L_i the size of candidate i's full log. One corpus trace is followed by two exact reads per candidate. `listChildren()` uses `sessionQuery.listEvents(childId)` to locate the sole descriptor event and `sessionQuery.readEvent({ sessionId: childId, seq })` to read it, and each operation independently loads the logical session. In the persisted-only worst case, every exact read repeats `persistence.list()`, loads the full child log, and clones its events, for O(D × C + Σ L_i) work up to constant factors; a live child instead takes two detached in-memory snapshots of its full log. The persisted path may durably repair an interrupted child log by appending synthetic closing events. The first version accepts the repeated reads as the no-index correctness baseline, but deployments must treat total corpus and child-log size—not only direct-child count—as the capacity constraint. Listing creates no Agent and appends no catalog or descriptor event itself, but it is not a storage-read-only operation. The model-hidden descriptor remains outside the conversation surface and survives compaction, so compacted and uncompacted children must enumerate identically. +This descriptor-read path is the correctness baseline, not a claim that work is linear only in the number of direct children. Let D be the number of direct-child candidates, C be the number of persisted sessions scanned by each persistence listing, and L_i be the size of candidate i's full log. One corpus trace is followed by `sessionQuery.listEvents(childId)` for every candidate. A candidate with no descriptor is omitted, and one with multiple descriptors is diagnosed without another read; only a candidate with exactly one descriptor is loaded again through `sessionQuery.readEvent({ sessionId: childId, seq })`. The read must return the same immutable session header observed by the trace, including the direct-parent relationship, and its target must still be the located descriptor event; a mismatch is per-child corruption. In the persisted-only worst case, each exact read repeats `persistence.list()`, loads the full child log, and clones its events, for O(D × C + Σ L_i) work up to constant factors; a candidate with exactly one descriptor pays those costs twice, while other candidates pay them once. A live candidate similarly takes one detached in-memory snapshot of its full log, or two when its descriptor is read. The persisted path may durably repair an interrupted child log by appending synthetic closing events. The first version accepts these repeated reads as the no-index correctness baseline, but deployments must treat total corpus and child-log size—not only direct-child count—as the capacity constraint. Listing creates no Agent and appends no catalog or descriptor event itself, but it is not a storage-read-only operation. The model-hidden descriptor remains outside the conversation surface and survives compaction, so compacted and uncompacted children must enumerate identically. If measured scale later requires an index, that index is derived state: session headers and child descriptors remain authoritative, and rebuilding or corruption fallback must reproduce the same results. An index cannot become a second authorization source or make an unpublished child visible. ### `list_agents` contract -`SubagentService.listChildren(parent)` returns every direct continuable child found in the session trace plus non-fatal diagnostics for candidates it could not read or validate. Each child carries its session id, descriptor `label`, and one of two snapshot states: +`SubagentService.listChildren(parentSessionId: SessionId)` returns `Promise`, one array preserving the trace's candidate order rather than separate child and diagnostic arrays. `SubagentListEntry` is a closed union discriminated by its readonly `kind`: -- `running`: the logical session record is live in `ctx.sessions`; -- `complete`: the logical session record exists only in persistence and may be resumed by `send_message`. +- `kind: 'child'` carries readonly `id: SessionId`, durable `label: string`, and `status: 'running' | 'complete'`; +- `kind: 'diagnostic'` carries readonly `id: SessionId` and `reason: 'corrupt' | 'unsupported' | 'unavailable'`. -These values are neither `AgentStatus` nor the manager's internal Activation state. Children are sorted by `SessionHeader.createdAt` ascending, then child id ascending; diagnostics follow their candidate's same key. The model-facing `list_agents` tool takes no arguments and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control` that renders the complete sorted children and diagnostics together. +A valid descriptor produces one child entry, a per-child inspection failure produces one diagnostic entry, and a candidate without a descriptor produces no entry. Child status `running` means that the logical record is live in `ctx.sessions`; `complete` means that it exists only in persistence. These values are neither `AgentStatus` nor the manager's internal Activation state, and the result does not expose the internal `createdAt` sorting key. Exact Activation states and durable outcomes such as successful completion, failure, cancellation, and stop reason require a separate durable activation record and are outside this proposal. -Diagnostics use three fixed reasons. Malformed event surfaces, conflicting headers discovered during an exact child load, malformed descriptor content, and multiple descriptor events map to `corrupt`. An unknown descriptor version maps to `unsupported`. `SESSION_QUERY_SESSION_NOT_FOUND`, `SESSION_QUERY_EVENT_NOT_FOUND`, and `SESSION_QUERY_PERSISTENCE_FAILED` from a per-child read map to `unavailable`. This phase boundary is intentional: a persistence outage during the initial trace fails the operation, while the same outage beginning during candidate reads may produce one identical `unavailable` diagnostic per affected child; v1 neither coalesces those diagnostics nor promotes them to a global failure. A missing descriptor is instead a one-shot exclusion without a diagnostic. Configuration/window errors and unrecognized failures are not child diagnostics and propagate as operation failures. Each diagnostic identifies the child id and reason without exposing model-hidden descriptor content; the candidate is omitted while healthy siblings remain visible. Sessions outside the trace's direct descendants are never read and produce no diagnostic. +The model-facing `list_agents` tool takes no arguments, derives `parentSessionId` from the current execution Agent, and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. It renders entries in array order as ` [] —