From c9cd76464a2b04989989c6de37bf22726c22082b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:40:21 +0800 Subject: [PATCH 001/402] fix(release): isolate packed-install npm cache --- ...packed-install-private-npm-cache.i18n.yaml | 6 ++ ...-09-06-packed-install-private-npm-cache.md | 29 +++++++ ...-06-packed-install-private-npm-cache.zh.md | 29 +++++++ ...2026-08-10-npm-release-sequences.i18n.yaml | 4 +- .../2026-08-10-npm-release-sequences.md | 2 +- .../2026-08-10-npm-release-sequences.zh.md | 2 +- scripts/release/verify-packed-install.spec.ts | 82 +++++++++++++++++++ scripts/release/verify-packed-install.ts | 5 +- 8 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-cache.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-cache.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-cache.zh.md create mode 100644 scripts/release/verify-packed-install.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-cache.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-cache.i18n.yaml new file mode 100644 index 0000000000..fbf989f47b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-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/bug-fix/2026-09-06-packed-install-private-npm-cache.md +2026-09-06-packed-install-private-npm-cache.md: ce25230c8f43a30237f7689b552a506686a1397e +2026-09-06-packed-install-private-npm-cache.zh.md: 7e495439e49731d404c7bdc4fde59c841d227364 diff --git a/.agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-cache.md b/.agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-cache.md new file mode 100644 index 0000000000..ce25230c8f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-cache.md @@ -0,0 +1,29 @@ +# Agent Note: Packed-install verification owns its npm cache + +Status: implemented + +English | [中文](2026-09-06-packed-install-private-npm-cache.zh.md) + +## Problem + +The [packed-install verifier](../../../../scripts/release/verify-packed-install.ts) removes its temporary consumer but an npm install using the ambient cache leaves tarball content and logs outside that cleanup. Repeated release rehearsals can therefore grow the shared runner user cache even when every consumer is removed. A full root filesystem can fail unrelated CI jobs. + +## Decision + +The verifier passes `--cache /.npm-cache` only to its npm install invocation. The explicit argument overrides npm environment and user-configuration cache settings without changing the environment of the installed executable. The existing `finally` removes this cache together with the consumer after success, an install failure, or an executable failure. + +The [release sequence decision](../process/2026-08-10-npm-release-sequences.md) still owns artifact selection and publication. This decision only adds cache ownership to the verifier; it does not change other npm consumers. + +## Alternatives considered + +- **Delete the shared user cache:** unrelated jobs also own its contents. The verifier must not delete shared host state. +- **Change global `TMPDIR` or npm configuration:** that affects processes outside this invocation and is unnecessary when the consumer already has an owned temporary directory. +- **Keep a persistent verifier cache:** that retains cross-run downloads but requires a separate retention policy. Invocation ownership accepts repeated downloads instead. + +## Consequences + +This prevents this verifier from adding content to the ambient npm cache; it neither reclaims existing host cache contents nor guarantees that disk exhaustion from other producers stops. External dependencies can require fresh downloads on each invocation. A forced termination that prevents `finally` from running can leave the consumer and its cache behind; this change adds no crash-recovery cleanup. + +## Verification + +[The focused regression](../../../../scripts/release/verify-packed-install.spec.ts) runs the real verifier and npm offline against a tiny local tarball. A lifecycle script records actual private `_cacache` files before cleanup. The tests verify consumer removal after success, npm install failure, and installed-entry failure, while inherited environment and user-config cache canaries remain unchanged. Removing the cache argument makes the private-cache assertion fail. The executable test follows the POSIX release runner path; it does not exercise Windows command-shim resolution. diff --git a/.agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-cache.zh.md b/.agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-cache.zh.md new file mode 100644 index 0000000000..7e495439e4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-06-packed-install-private-npm-cache.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 打包安装验证拥有独立 npm 缓存 + +Status: implemented + +[English](2026-09-06-packed-install-private-npm-cache.md) | 中文 + +## 问题 + +[打包安装验证器](../../../../scripts/release/verify-packed-install.ts) 会删除临时消费目录,但使用环境配置缓存的 npm install 会把 tarball 内容和日志留在清理范围之外。因此,即使每个消费目录均被删除,重复的发布演练仍可能扩大共享 runner 用户缓存。根文件系统满载会使无关 CI 作业失败。 + +## 决策 + +验证器仅向自己的 npm install 调用传入 `--cache /.npm-cache`。显式参数覆盖 npm 环境变量和用户配置中的缓存设置,不改变已安装可执行文件的环境。现有 `finally` 在成功、安装失败或可执行文件失败后将此缓存与消费目录一起删除。 + +[发布序列决策](../process/2026-08-10-npm-release-sequences.zh.md) 仍负责产物选择与发布。本决策仅为验证器增加缓存所有权,不改变其他 npm 消费方。 + +## 曾考虑的替代方案 + +- **删除共享用户缓存:**其他作业也拥有其中的内容。验证器不得删除共享宿主状态。 +- **修改全局 `TMPDIR` 或 npm 配置:**这会影响本次调用之外的进程,而消费目录已有独立临时目录,因此没有必要。 +- **保留持久化验证缓存:**这能跨运行复用下载,但需要独立保留策略。调用级所有权选择接受重复下载。 + +## 后果 + +这会阻止该验证器向环境配置的 npm 缓存添加内容;既不会回收现有宿主缓存,也不保证其他来源导致的磁盘耗尽停止。外部依赖可能需要在每次调用时重新下载。若强制终止使 `finally` 无法运行,消费目录及其缓存仍可能残留;本改动不增加崩溃恢复清理。 + +## 验证 + +[定向回归测试](../../../../scripts/release/verify-packed-install.spec.ts) 使用微型本地 tarball 离线运行真实验证器和 npm。生命周期脚本在清理前记录私有 `_cacache` 中的实际文件。测试验证成功、npm 安装失败和已安装入口失败后消费目录均被删除,同时继承环境和用户配置指定缓存中的探针文件保持不变。移除缓存参数会使私有缓存断言失败。可执行测试沿用 POSIX 发布 runner 路径,不覆盖 Windows 命令垫片解析。 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index 96e9ba396f..d6d6a4b4bb 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.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/process/2026-08-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: 6729b506a9cfe7f6d4410dbf0750621901e07293 -2026-08-10-npm-release-sequences.zh.md: 18a30d6dd57c17b8e13cc3a1f71bc91242a391ff +2026-08-10-npm-release-sequences.md: f26298c74e2ce148ff49b8b5ce7625f4e7e0ab2e +2026-08-10-npm-release-sequences.zh.md: c74f9b3dc483279aeae802c538ebfe7dbbdc2000 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index 6729b506a9..f26298c74e 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -107,7 +107,7 @@ The entity in this domain is a **release family**: a set of packages sharing one | `publishOrder` | topological order over the sections npm installs plus peer declarations, ties broken by package name; a cycle among installed dependencies is reported rather than resolved arbitrarily, and a peer edge no order can honour is dropped and named | | `pack` | packs a whole family into one directory and records the upload order | | `verify` | the family's version baseline, the publish order it prints in full, and — when publishing — that the run comes from that family's tag and its members are publishable | -| `verify-packed-install` | installs the tarballs of one or more pack directories into a throwaway consumer and drives the installed executable | +| `verify-packed-install` | installs the tarballs of one or more pack directories into a throwaway consumer with an [owned npm cache](../bug-fix/2026-09-06-packed-install-private-npm-cache.md) and drives the installed executable | | `publish` | the three registry states above | | `process` / `tarball` | the one home for spawning commands and for reading a packed tarball, including the entry guard that keeps every script importable | diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index 18a30d6dd5..c74f9b3dc4 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -107,7 +107,7 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间 | `publishOrder` | 按 npm 会安装的依赖段加 peer 声明做拓扑序,同层按包名排;安装依赖成环是报错而不是随意定序,任何排不进去的 peer 边被丢弃并点名 | | `pack` | 把整族打进一个目录并记录上传顺序 | | `verify` | 族的版本基线、完整打印出来的发布顺序;发布时还要求本次运行来自该族的 tag、且成员可发布 | -| `verify-packed-install` | 把一个或多个 pack 目录的 tarball 装进一次性 consumer,并驱动已安装的可执行入口 | +| `verify-packed-install` | 把一个或多个 pack 目录的 tarball 装进带[独立 npm 缓存](../bug-fix/2026-09-06-packed-install-private-npm-cache.zh.md)的一次性 consumer,并驱动已安装的可执行入口 | | `publish` | 上面那三态 | | `process` / `tarball` | 启动命令、读取打包 tarball 的唯一正家,其中的入口守卫让每个脚本都可被 import | diff --git a/scripts/release/verify-packed-install.spec.ts b/scripts/release/verify-packed-install.spec.ts new file mode 100644 index 0000000000..b40f21ae92 --- /dev/null +++ b/scripts/release/verify-packed-install.spec.ts @@ -0,0 +1,82 @@ +/** Packed-install npm cache ownership through the real offline verifier entry. */ + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const root = resolve(import.meta.dirname, '../..') +const roots: string[] = [] + +afterEach(() => { + for (const directory of roots.splice(0)) rmSync(directory, { recursive: true, force: true }) +}) + +// The release verifier invokes POSIX npm directly, not the Windows npm.cmd shim. +describe.skipIf(process.platform === 'win32')('packed-install npm cache', () => { + // The verifier starts npm, lifecycle scripts, and the installed entry serially. + it.each(['success', 'install failure', 'entry failure'] as const)( + 'owns cache writes and removes them after %s', + (outcome) => { + const directory = mkdtempSync(join(tmpdir(), 'dsh-packed-cache-test-')) + roots.push(directory) + const source = join(directory, 'package') + const packed = join(directory, 'packed') + const ambient = join(directory, 'ambient-cache') + const configured = join(directory, 'configured-cache') + const temporary = join(directory, 'temporary') + for (const path of [join(source, 'lib'), packed, ambient, configured, temporary]) mkdirSync(path, { recursive: true }) + writeFileSync(join(ambient, 'sentinel'), 'ambient cache must survive\n') + writeFileSync(join(configured, 'sentinel'), 'configured cache must survive\n') + const npmrc = join(directory, 'npmrc') + writeFileSync(npmrc, `cache=${configured}\n`) + const evidence = join(directory, 'evidence.json') + const probe = ` +const fs = require('node:fs') +const path = require('node:path') +const consumer = process.env.INIT_CWD || process.cwd() +const cache = path.join(consumer, '.npm-cache', '_cacache') +const files = fs.existsSync(cache) ? fs.readdirSync(cache, { recursive: true }).filter(name => fs.statSync(path.join(cache, name)).isFile()) : [] +fs.writeFileSync(process.env.CACHE_EVIDENCE, JSON.stringify({ consumer, files })) +` + writeFileSync(join(source, 'probe.cjs'), probe + (outcome === 'install failure' ? '\nprocess.exit(37)\n' : '')) + writeFileSync(join(source, 'lib/bin.js'), outcome === 'entry failure' ? 'process.exit(37)\n' : 'console.log("0.0.1")\n') + writeFileSync(join(source, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh', + version: '0.0.1', + scripts: { postinstall: 'node probe.cjs' }, + })) + const archive = spawnSync('tar', ['-czf', join(packed, 'dsh.tgz'), '-C', directory, 'package'], { encoding: 'utf8', timeout: 30_000 }) + expect(archive.error).toBeUndefined() + expect(archive.signal).toBeNull() + expect(archive.status, archive.stderr).toBe(0) + const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^npm_config_/i.test(key))) + Object.assign(environment, { + npm_config_cache: ambient, + npm_config_userconfig: npmrc, + npm_config_offline: 'true', + npm_config_ignore_scripts: 'false', + CACHE_EVIDENCE: evidence, + TMPDIR: temporary, + TMP: temporary, + TEMP: temporary, + }) + const result = spawnSync(process.execPath, [ + '--import', 'tsx', 'scripts/release/verify-packed-install.ts', '--family', 'dsh', '--from', packed, + ], { cwd: root, env: environment, encoding: 'utf8', timeout: 120_000 }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + expect(result.status, result.stdout + result.stderr).toBe(outcome === 'success' ? 0 : 1) + const observation = JSON.parse(readFileSync(evidence, 'utf8')) as { consumer: string; files: string[] } + expect(observation.files.length, 'npm must write tarball content into the consumer-private cache').toBeGreaterThan(0) + expect(observation.consumer).toContain('dsh-packed-dsh-') + expect(existsSync(observation.consumer), 'the verifier finally must remove its consumer and cache').toBe(false) + expect(readdirSync(ambient)).toEqual(['sentinel']) + expect(readFileSync(join(ambient, 'sentinel'), 'utf8')).toBe('ambient cache must survive\n') + expect(readdirSync(configured)).toEqual(['sentinel']) + expect(readFileSync(join(configured, 'sentinel'), 'utf8')).toBe('configured cache must survive\n') + }, + 180_000, + ) +}) diff --git a/scripts/release/verify-packed-install.ts b/scripts/release/verify-packed-install.ts index 65b8ba7691..6e7e8a4c53 100644 --- a/scripts/release/verify-packed-install.ts +++ b/scripts/release/verify-packed-install.ts @@ -1,6 +1,7 @@ /** * Install packed tarballs into a throwaway consumer outside the repository and - * drive the installed executable with plain Node. + * drive the installed executable with plain Node. Its npm cache belongs to that + * consumer and is removed with it on normal completion or failure. * * Every tarball the installed tree needs comes from `--from`, so the only * registry traffic is for external dependencies. That matters beyond hermetic @@ -104,7 +105,7 @@ function main(): void { // that cannot install them must still start — which is what optional means // here. Their entry package is a plain dependency of dsh-sandbox-local, so // its tarball is supplied through --from. - capture('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false', '--omit=optional'], + capture('npm', ['install', '--cache', join(consumerRoot, '.npm-cache'), '--no-audit', '--no-fund', '--package-lock=false', '--omit=optional'], { cwd: consumerRoot, env: environment }) const bin = join(consumerRoot, 'node_modules', ...entry.packageName.split('/'), entry.binPath) From c042eb2560c8c720c57522e72e16e65b804f3ce1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:03:32 +0800 Subject: [PATCH 002/402] fix(benchmarks): sample Trajectory readiness on animation frames --- ...-06-frontend-performance-budgets.i18n.yaml | 4 +-- ...2026-09-06-frontend-performance-budgets.md | 10 +++++++ ...6-09-06-frontend-performance-budgets.zh.md | 10 +++++++ .../long-session-browser/README.i18n.yaml | 4 +-- benchmarks/long-session-browser/README.md | 2 ++ benchmarks/long-session-browser/README.zh.md | 2 ++ .../long-session.bench.ts | 30 +++++++++++++++++-- 7 files changed, 56 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml index 867520af75..8dc9fa2692 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.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/testing/2026-09-06-frontend-performance-budgets.md -2026-09-06-frontend-performance-budgets.md: 2ccc992add9a1f7fc7824a3e0ed604a9582def56 -2026-09-06-frontend-performance-budgets.zh.md: bfe31fd4c8c61b3b89336a8d9d8234d1e76ba040 +2026-09-06-frontend-performance-budgets.md: 36a7e6fc97a2ca69d5b58e9cebae4278344c659d +2026-09-06-frontend-performance-budgets.zh.md: bdb27d782b97bde4a02f116de0f0522a0a1531c5 diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md index 2ccc992add..36a7e6fc97 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md @@ -68,6 +68,14 @@ Run 34036109842, job 101494445658 records open samples of 875.306861/1083.683529 A controlled mouse-refocus delay waits for the real DONE marker without pausing replay: the mouse path rejects a trusted input after DONE, while Enter submission and keyboard-only draft input pass all three samples under the same control. The delay is diagnostic-only. A clean three-sample run on arm64 Node 24.19.0 / Chromium 149.0.7827.55 reports first-reply/input/complete-wall medians of 288.823/418.868/2567.328 ms, with actual overlap and post-DONE rejection in every sample. This proves removal of the mouse-action scheduling dependency, not the cause of a particular hosted stall; all workload constants and budgets remain fixed. +### Trajectory readiness polling + +Trajectory activation samples its visible searchbox and a visible record inside `[data-trajectory-scroll]` on animation frames. Playwright's selector polling uses increasing delays up to 500 ms, so DOM readiness can precede the observer by hundreds of milliseconds. The trusted tab click, its actionability checks and the final two-frame wait remain timed; workload and the 625 ms budget are unchanged. + +A controlled macOS arm64 diagnostic on Node 24.19.0, Chromium 149.0.7827.55 and Playwright 1.61.1 makes the searchbox and record ready after 300 or 350 ms. Selector waits take 793.082/791.053 ms and observe readiness 493.5/439.2 ms late; frame sampling takes 307.324/357.423 ms and observes it 8.1/7.9 ms late. This isolates conditional observer delay, not product latency or the cause of the first #4119 CI miss. + +Complete local 240-turn runs produce Trajectory samples of 157.795/146.737/148.571 ms before the observer change and 130.184/191.247/157.543 ms afterward, with medians 148.571 and 157.543 ms. Both runs pass every fixed endpoint budget; no local speedup or hosted calibration is claimed. The readiness regression rejects a hidden searchbox or record despite a visible unrelated table row; restoring the original selector waits makes that test fail. + ## Alternatives considered **Use the Node fold as paint evidence.** Rejected because it never performs DOM mutation, layout, or browser scheduling. The focused reconnect case likewise makes no GUI speed claim. @@ -78,6 +86,8 @@ A controlled mouse-refocus delay waits for the real DONE marker without pausing **Search the entire loaded history for every stream marker.** Rejected because Playwright injects text and accessibility scans into the same renderer whose CPU the benchmark measures. Scoping queries to the composer and latest Assistant preserves visible completion checks without making observer cost proportional to loaded history. +**Raise the Trajectory budget or retry failed samples.** This retains the measured selector backoff and can accept slower product behavior. Frame-based readiness removes that observer delay without changing the budget. + **Measure stream CPU alone.** Rejected because transport stalls and final-settlement delays can leave main-thread CPU low. The independent input, first-reply, and complete-wall budgets cover those waits. ## Consequences diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md index bfe31fd4c8..bdb27d782b 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md @@ -68,6 +68,14 @@ Node 对话折叠很快,并不能证明浏览器能绘制长对话或在流式 受控的鼠标重新聚焦延迟等待真实 DONE 标记,不暂停重放:鼠标路径拒绝 DONE 之后的真实输入,而 Enter 提交与纯键盘草稿输入在相同对照下通过全部三个样本。该延迟仅用于诊断。在 arm64 Node 24.19.0 / Chromium 149.0.7827.55 上,不含延迟的三个样本报告首段回复/输入/完整壁钟中位数 288.823/418.868/2567.328 ms,每个样本均满足实际重叠并拒绝 DONE 之后的输入。这证明移除了鼠标操作调度依赖,并不证明某次托管停顿的原因;全部工作负载常量和预算保持固定。 +### Trajectory 就绪轮询 + +Trajectory 激活在动画帧上采样可见搜索框与 `[data-trajectory-scroll]` 内的可见记录。Playwright 选择器轮询使用逐步增加、最高 500 ms 的延迟,因此 DOM 可能比观察器早数百毫秒就绪。真实 tab 点击、其可交互性检查与最后两帧等待仍计时;工作负载与 625 ms 预算不变。 + +在 macOS arm64、Node 24.19.0、Chromium 149.0.7827.55 和 Playwright 1.61.1 上,受控诊断使搜索框与记录在 300 或 350 ms 后就绪。选择器等待耗时 793.082/791.053 ms,晚 493.5/439.2 ms 观察到就绪;动画帧采样耗时 307.324/357.423 ms,晚 8.1/7.9 ms 观察到就绪。这隔离了有条件发生的观察器延迟,不代表产品延迟,也不能证明 #4119 首次 CI 超限的原因。 + +完整本地 240 轮运行在修改观察器前得到 Trajectory 样本 157.795/146.737/148.571 ms,修改后为 130.184/191.247/157.543 ms,中位数分别为 148.571 和 157.543 ms。两次运行均通过所有固定终点预算;不声称本地提速或托管环境校准。即使存在可见的无关表格行,就绪回归测试仍拒绝隐藏的搜索框或记录;恢复原选择器等待会使该测试失败。 + ## 考虑过的替代方案 **用 Node 折叠作为绘制证据。** 拒绝,因为它不执行 DOM 修改、布局或浏览器调度。聚焦重连用例同样不声称 GUI 提速。 @@ -78,6 +86,8 @@ Node 对话折叠很快,并不能证明浏览器能绘制长对话或在流式 **每次流式标记查找都搜索全部已加载历史。** 拒绝,因为 Playwright 把文本与无障碍扫描注入同一个被测 CPU 的渲染进程。将查询限定在输入框和最新 Assistant 中,可保留可见完成检查,同时避免观察器成本随已加载历史增长。 +**提高 Trajectory 预算或重试失败样本。** 这会保留已测得的选择器退避,并可能接受更慢的产品行为。基于动画帧的就绪观察移除该观察器延迟,不改变预算。 + **只测量流式 CPU。** 拒绝,因为传输停顿和最终结算延迟可能不增加主线程 CPU。独立的输入、首段回复和完整壁钟预算覆盖这些等待。 ## 影响 diff --git a/benchmarks/long-session-browser/README.i18n.yaml b/benchmarks/long-session-browser/README.i18n.yaml index 060c1346dc..67cca883f5 100644 --- a/benchmarks/long-session-browser/README.i18n.yaml +++ b/benchmarks/long-session-browser/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 benchmarks/long-session-browser/README.md -README.md: d6bd253dedb2c6cdea7fec1019a14bfcc3c4c56f -README.zh.md: fe593b833002ece8efd18d07707c10708cb76e2e +README.md: 05ae2321aa661da3aec6074c4b9dd90bec955390 +README.zh.md: 5d5b005bc1be9a9acb48874748ff7eec0ac43772 diff --git a/benchmarks/long-session-browser/README.md b/benchmarks/long-session-browser/README.md index d6bd253ded..05ae2321aa 100644 --- a/benchmarks/long-session-browser/README.md +++ b/benchmarks/long-session-browser/README.md @@ -12,6 +12,8 @@ The required Chromium workflow in [long-session.bench.ts](long-session.bench.ts) Three fresh browser processes and scaffold worlds produce raw samples and median verdicts. Open and paging end after the expected transcript state and two animation frames; this includes a rendering opportunity, not a hardware presentation timestamp. Paging reports every page and gates the median of each sample’s slowest page. Stream reports first visible reply, trusted draft typing, complete reply wall time, and Chromium main-thread task duration. Enter submits from the focused composer; draft typing retains that focus without a mouse click. Reply-marker lookups and the input-event text witness read only the latest Assistant step, avoiding repeated whole-history text and accessibility scans. The input witness is installed before submission, and draft typing starts as soon as the first marker is visible, without an extra pre-input animation-frame wait. Reply markers are sampled on animation frames, with visible text required inside the latest step. The first observation captures marker state and focus in the browser; its diagnostics are retrieved after typing so they add no pre-input round trip. Diagnostics also include browser-clock timestamps and focus at the first input event. They do not pause replay; a delayed first observation or input can still fail overlap. The actual first input event must observe an unfinished reply; completion waits for the new rendered turn-tail after Host settlement. After measurement, a trusted keystroke after DONE must fail the same overlap assertion. Open, the slowest older page, and first Trajectory use standard-hosted expectations of 900/700/500 ms. Shared 1.25× headroom gives limits of 1125/875/625 ms respectively; stream endpoint overhead budgets are unchanged. Heap after forced GC and DOM counts are diagnostics, not leak budgets. +Trajectory readiness is sampled on animation frames from the visible searchbox and a visible record inside its own table. The trusted tab click and final two-frame wait remain inside the measured interval; hidden records and unrelated table rows cannot satisfy readiness. + The fixture reserves an empty system head before the first user message, with each user message inside its step. It contains mixed-language prompts, prose, reasoning, 20 code fences, and 40 synthetic tool results. Every historical Assistant includes a compact stream built by the production accumulator from matching reasoning, text, tool arguments, usage, and finish chunks. No model, tool, external network, recorded Session, or private Harness home supplies its content. Streaming uses 120 text deltas at 16 ms replay pacing through the real composer, agent loop, transport, and persistence. The [decision record](../../.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md) owns calibration, exclusions, and alternatives. The larger [manual diagnostic](../../apps/web/tests/complex-history.perf.ts) remains separate. diff --git a/benchmarks/long-session-browser/README.zh.md b/benchmarks/long-session-browser/README.zh.md index fe593b8330..5d5b005bc1 100644 --- a/benchmarks/long-session-browser/README.zh.md +++ b/benchmarks/long-session-browser/README.zh.md @@ -12,6 +12,8 @@ 三个全新浏览器进程与 scaffold 环境产生原始样本及中位数判定。打开和分页在预期 transcript(文本记录)状态出现且经过两次动画帧后结束;这包含一次渲染机会,而非硬件显示时间戳。分页报告每一页,并对各样本最慢分页时间的中位数执行预算检查。流式报告首次可见回复、受信任的草稿键入、完整回复壁钟时间和 Chromium 主线程任务时间。Enter 从已聚焦的输入框提交;草稿键入保留该焦点,不执行鼠标点击。回复标记查找与输入事件文本观察器仅读取最新 Assistant 步骤,避免重复进行全量历史文本扫描和无障碍扫描。输入事件文本观察器在提交前安装,首个标记可见后立即开始草稿键入,不额外等待输入前动画帧。回复标记在动画帧上采样,要求文本在最新步骤内可见。首次观察在浏览器内记录标记状态与焦点;诊断在键入后取回,不增加输入前的通信往返。诊断还包含首个输入事件的浏览器时钟时间戳与焦点。诊断不会暂停回放;首次观察或输入延迟仍可能导致重叠失败。实际首个输入事件必须观察到未完成的回复;完整回复的测量会在 Host 结算后等待新 turn-tail 渲染完成。测量后,在 DONE 之后发送的受信任按键必须无法通过同一个重叠断言。打开、最慢的较早页面和首次 Trajectory 使用标准托管预期 900/700/500 ms。共享的 1.25× 余量分别产生 1125/875/625 ms 上限;流式终点的额外开销预算不变。强制 GC 后的 heap 与 DOM 数量仅供诊断,不作为泄漏预算。 +Trajectory 就绪状态在动画帧上采样,要求搜索框可见且其自身表格中存在可见记录。真实 tab 点击和最后的两帧等待仍计入测量区间;隐藏记录与无关表格行不能满足就绪条件。 + fixture(测试前置数据)在首条用户消息前保留空 system 头节点,每条用户消息都位于其步骤内。它包含混合语言提示词、正文、推理(reasoning)、20 个围栏代码块和 40 个合成工具结果。每条历史 Assistant 都含紧凑流,由生产 accumulator 从匹配的推理、文本、工具参数、usage 和 finish 分片构建。其内容不来自模型、工具、外部网络、录制会话或私有 Harness 主目录。流式回复以 16 ms 回放间隔发送 120 个文本 delta,经过真实输入框、agent loop(智能体循环)、传输与持久化。 [决策记录](../../.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md)拥有校准、排除项与替代方案。更大规模的[手动诊断](../../apps/web/tests/complex-history.perf.ts)保持独立。 diff --git a/benchmarks/long-session-browser/long-session.bench.ts b/benchmarks/long-session-browser/long-session.bench.ts index 7c5e1ef9ca..701dfe755a 100644 --- a/benchmarks/long-session-browser/long-session.bench.ts +++ b/benchmarks/long-session-browser/long-session.bench.ts @@ -68,6 +68,17 @@ async function waitForReplyMarker(page: Page, marker: string, timeout = 30000) { }, { marker, first: FIRST, done: DONE }, { polling: 'raf', timeout }) } +async function waitForTrajectory(page: Page, timeout = 30000): Promise { + // Selector retry backoff can delay observation after the view is already ready. + const ready = await page.waitForFunction(() => { + const search = document.querySelector('input[type="search"][aria-label="Search trajectory"]') + if (!search?.checkVisibility({ checkVisibilityCSS: true })) return false + return Array.from(document.querySelectorAll('[data-trajectory-scroll] [data-trajectory-row-key]')) + .some(row => row.checkVisibility({ checkVisibilityCSS: true })) + }, undefined, { polling: 'raf', timeout }) + await ready.dispose() +} + async function watchInputOverlap(composer: Locator): Promise { await composer.evaluate((element, markers) => { element.removeAttribute('data-benchmark-input-witness') @@ -127,6 +138,22 @@ it('waits for visible marker text in the latest Assistant step', async () => { } }) +it('waits for visible Trajectory controls and records rather than unrelated table rows', async () => { + const browser = await chromium.launch({ headless: true }) + try { + const page = await browser.newPage() + await page.setContent('
unrelated
record
') + await expect(waitForTrajectory(page, 100)).rejects.toThrow('Timeout') + await page.locator('[data-trajectory-row-key]').evaluate(element => { element.style.display = 'none' }) + await page.locator('input').evaluate(element => { element.style.visibility = 'visible' }) + await expect(waitForTrajectory(page, 100)).rejects.toThrow('Timeout') + await page.locator('[data-trajectory-row-key]').evaluate(element => { element.style.display = 'table-row' }) + await waitForTrajectory(page) + } finally { + await browser.close() + } +}) + it('opens, pages, navigates and streams into a 240-turn browser history', async () => { if (webSnapshotMode() !== 'replay') throw new Error('browser benchmarks require keyless replay mode') const samples: { open: number; page: number; trajectory: number; first: number; streamTask: number; streamWall: number; input: number; inputOverlapped: boolean; heapMb: number; nodes: number }[] = [] @@ -172,8 +199,7 @@ it('opens, pages, navigates and streams into a 240-turn browser history', async } const trajectory = await measure(page, async () => { await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() - await page.getByRole('searchbox', { name: 'Search trajectory', exact: true }).waitFor() - await page.getByRole('row').last().waitFor() + await waitForTrajectory(page) }) await page.getByRole('tab', { name: 'Chat', exact: true }).click() await page.waitForFunction(({ selector, expected }) => document.querySelectorAll(selector).length === expected, { selector: TAIL, expected: HISTORY_TURNS }) From 4565dafc58cbc49028ea6b21ed47fb78c0a7e338 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 17 Sep 2026 10:57:12 +0800 Subject: [PATCH 003/402] refactor(subagent): default base fork delegation to continuable --- .../2026-08-10-fork-children-stay-one-shot.i18n.yaml | 4 ++-- .../2026-08-10-fork-children-stay-one-shot.md | 4 ++-- .../2026-08-10-fork-children-stay-one-shot.zh.md | 4 ++-- packages/bundle/base/README.i18n.yaml | 4 ++-- packages/bundle/base/README.md | 2 ++ packages/bundle/base/README.zh.md | 2 ++ packages/bundle/base/cordis.patch.yml | 7 +++---- .../subagent/subagent-fork-in-process/README.i18n.yaml | 4 ++-- packages/subagent/subagent-fork-in-process/README.md | 8 ++++---- packages/subagent/subagent-fork-in-process/README.zh.md | 8 ++++---- snapshots/sdk/text-turn/system-prompt.expected.md | 2 ++ snapshots/sdk/text-turn/tool-schemas.expected.json | 4 ++-- 12 files changed, 29 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml index 44a8fbf89f..a5bde488e1 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.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-08-10-fork-children-stay-one-shot.md -2026-08-10-fork-children-stay-one-shot.md: 0edf6ca9347dcf57313365e570daed22589f69fd -2026-08-10-fork-children-stay-one-shot.zh.md: c2e3504bd21ec6dd6b3feba4ed8abdaec3ee45a4 +2026-08-10-fork-children-stay-one-shot.md: 27f5a4101f2ffac494d923cc82a4a5759e6db30f +2026-08-10-fork-children-stay-one-shot.zh.md: 6f0b632744c9e5530c09fb31ff6e9cf6f18f53aa diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md index 0edf6ca934..27f5a4101f 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md @@ -14,7 +14,7 @@ The earlier shipped composition avoided this mismatch by keeping forked children The model-facing `send_message` tool is registered globally for every Agent in a composition. A continuable forked child therefore receives the same tool name, description, schema, and ordering as its parent. Its initial task is appended after the inherited Session seed, and the task includes the direct parent id plus guidance to return results with `send_message({ agent_id, message })` when that tool is visible to the child. -The base and headless compositions retain one-shot fork as their conservative lifecycle policy. The `cordis`, `standard`, and `ptc` CLI presets may bind fork to the continuable lifecycle because that binding no longer inserts child-only request-head fields. `ForkInProcessProvider.prepareContinuable()` and `ctx.subagents.startContinuable()` remain the implementation seam for those presets. +The base bundle and the `cordis`, `standard`, and `ptc` CLI presets bind fork to the continuable lifecycle. Base-backed profiles, including headless, inherit that default. `ForkInProcessProvider.prepareContinuable()` and `ctx.subagents.startContinuable()` preserve the same request-prefix behavior across these compositions. Byte-identical prefix reuse is qualified by explicit deployment choices. A fork delegation that applies a child persona or `toolFilter` may still change the request head. In particular, filtering out `send_message` removes both the schema and the return guidance from the child; the runtime does not bypass an explicit allow-list. @@ -32,7 +32,7 @@ Byte-identical prefix reuse is qualified by explicit deployment choices. A fork - Parent and continuable-fork child expose byte-identical ordered tool schemas when the delegation does not request a persona or tool filter. - The inherited Session seed precedes the child's initial task and return guidance. -- The base and headless profiles keep one-shot fork, while selected CLI presets exercise continuable fork without a child-only request-head addition. +- The base bundle and full CLI presets default to continuable fork without a child-only request-head addition; explicit foreground calls still use the one-shot path. - A child sends zero or more messages to its direct parent explicitly; its final answer is not implicitly copied. The manager-owned settlement notice remains unconditional and separate. - Keyless snapshots and package tests pin schema equality, inherited-history ordering, parent-id guidance, and child-to-parent delivery through the same `send_message` operation used in the other direction. diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md index c2e3504bd2..6f0b632744 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md @@ -14,7 +14,7 @@ fork 与 spawn 的差异在于:fork 会用 parent 已完成轮次的前缀作 面向模型的 `send_message` 工具在组合中的每个 Agent 上全局注册。因此,可继续 fork child 获得与 parent 相同的工具名称、描述、schema 和顺序。其初始任务追加在继承的 Session 种子之后;当 child 可以看到该工具时,任务还会包含直接 parent id,以及使用 `send_message({ agent_id, message })` 返回结果的指引。 -base 与 headless 组合保留 one-shot fork 作为其保守生命周期策略。`cordis`、`standard` 和 `ptc` CLI preset 可以把 fork 绑定为可继续生命周期,因为该绑定不再插入 child-only 请求头字段。`ForkInProcessProvider.prepareContinuable()` 与 `ctx.subagents.startContinuable()` 仍是这些 preset 使用的实现 seam。 +base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为可继续生命周期。基于 base 的 profile(包括 headless)继承这一默认值。`ForkInProcessProvider.prepareContinuable()` 与 `ctx.subagents.startContinuable()` 在这些组合中保持相同的请求前缀行为。 逐字节相同的前缀复用受显式部署选择约束。配置 child persona 或 `toolFilter` 的 fork 委派仍可能改变请求头。尤其是过滤掉 `send_message` 时,child 会同时失去该 schema 与返回指引;运行时不会绕过显式 allow-list。 @@ -32,7 +32,7 @@ base 与 headless 组合保留 one-shot fork 作为其保守生命周期策略 - 未请求 persona 或工具过滤时,parent 与可继续 fork child 暴露逐字节相同且顺序一致的工具 schema。 - 继承的 Session 种子位于 child 初始任务及其返回指引之前。 -- base 与 headless profile 保持 one-shot fork;选定的 CLI preset 会在没有 child-only 请求头增量的前提下使用可继续 fork。 +- base 组合包与完整 CLI preset 默认使用可继续 fork,不增加 child-only 请求头字段;显式前台调用仍使用 one-shot 路径。 - child 显式向直接 parent 发送零条或多条消息;最终回答不会被隐式复制。管理器负责的结算通知仍然无条件执行,并且与 Agent 消息分离。 - keyless snapshot 与包测试固定 schema 相等性、继承历史顺序、parent-id 指引,以及通过同一个 `send_message` 操作完成的 child-to-parent 投递。 diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 292f948500..d8b1ef6cc4 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: f9cc3e4d03a0d71d1f598d97633c0f89f7700b42 -README.zh.md: 2e98bb710502b62ad87f04ae768833487b4b5787 +README.md: d6aee588dee09600c27d602e9ad25c4fde8d370a +README.zh.md: 7bce05e7b79518b3f7c951f1ac29141ac24e2d3a diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index f9cc3e4d03..d6aee588de 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -49,6 +49,8 @@ Run `dsh --profile my-profile "your task"` and you get a working agent with mode Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, public HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval; its provider rejects non-public destinations. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The default-on [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path. +Both `subagent` and `subagent_fork` default to continuable background children. They return a child id for later `send_message` calls; explicit `run_in_background: false` still waits for a one-shot result. [Delegation-tool behavior](../../subagent/tool-subagent/README.md) describes both paths. + Default file editing uses `read`, `write`, and `edit`. The `str_replace_editor` tool remains available as an explicit opt-in. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch: ```yaml diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 2e98bb7105..7bce05e7b7 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -49,6 +49,8 @@ kind: "package-bundle" 开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(提供方与模型可配置,你还可以在设置中启用额外提供方)、完整工具集——文件编辑、shell 命令、web 搜索、公开 HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,其提供方会拒绝非公开目的地址。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范会话日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。默认开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。 +`subagent` 与 `subagent_fork` 都默认创建可继续对话的后台子 agent,并返回 child id,供后续 `send_message` 调用使用;显式传入 `run_in_background: false` 仍会等待一次性结果。[委派工具行为](../../subagent/tool-subagent/README.zh.md)说明了这两条路径。 + 默认文件编辑使用 `read`、`write` 和 `edit`。`str_replace_editor` 工具仍可显式启用。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目: ```yaml diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 1aaf9da6b4..860dd2d912 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -361,9 +361,8 @@ backgroundMode: continuable # Fork omits model selection so provider/model stay equal to the parent and - # the inherited history remains eligible for KV Cache reuse. This base row - # stays one-shot; preset layers may select continuable mode without adding a - # child-only system-prompt section or tool schema ahead of that history. + # the inherited history remains eligible for KV Cache reuse. Parent and child + # share the messaging tools; return guidance follows the inherited history. # See .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md # and .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. - id: tool-subagent-fork @@ -371,7 +370,7 @@ config: provider: fork toolName: subagent_fork - backgroundMode: one-shot + backgroundMode: continuable - id: ptc-runtime name: '@deepseek-ai/dsh-ptc-runtime-node' diff --git a/packages/subagent/subagent-fork-in-process/README.i18n.yaml b/packages/subagent/subagent-fork-in-process/README.i18n.yaml index ab0bb07299..692666ac68 100644 --- a/packages/subagent/subagent-fork-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-fork-in-process/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-in-process/README.md -README.md: 0c8ff5372717b26a4e5ee556655094a74c9d6dda -README.zh.md: c1098825dc0d27f854e7c4d283670d84a507999d +README.md: 6796e1e79c45fa9c282219c2e9c2e0eeb9b38d8f +README.zh.md: 0359ee2083bd2d79a99c09776401cc169f311151 diff --git a/packages/subagent/subagent-fork-in-process/README.md b/packages/subagent/subagent-fork-in-process/README.md index 0c8ff53727..6796e1e79c 100644 --- a/packages/subagent/subagent-fork-in-process/README.md +++ b/packages/subagent/subagent-fork-in-process/README.md @@ -84,7 +84,7 @@ On `start`, the prefix is sliced from the parent's event log up to and including ### Lifecycle binding -The base bundle and ACP/headless examples bind this provider to `backgroundMode: one-shot`, while the CLI presets select `continuable`. Both preserve the inherited request prefix: parent and child receive the same messaging tool definition and ordering, and the continuable child's parent id and return guidance live in its initial user task after inherited history ([cache-preserving fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)). +The base bundle and full CLI presets bind this provider to `backgroundMode: continuable`; explicit foreground calls and one-shot compositions remain supported. Both preserve the inherited request prefix: parent and child receive the same messaging tool definition and ordering, and the continuable child's parent id and return guidance live in its initial user task after inherited history ([cache-preserving fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)). @@ -93,14 +93,14 @@ The base bundle and ACP/headless examples bind this provider to `backgroundMode: ## Further Exploration -Read these pages when the package-level contract is not enough; they move from the shared subagent model to the sibling backends and the design evidence for the one-shot binding. +Read these pages when the package-level contract is not enough; they move from the shared subagent model to the sibling backends and the design evidence for preserving the inherited request prefix. - [Subagent subsystem](../../../docs/subsystems/subagent.md) — start requests, results, provider contract, and in-process depth and seed. - [dsh-subagent-in-process-driver](../subagent-in-process-driver/README.md) — the shared run driver this backend calls. - [dsh-subagent-spawn-in-process](../subagent-spawn-in-process/README.md) — the fresh-child sibling backend. - [dsh-tool-subagent](../tool-subagent/README.md) — the model-facing delegation tool that reaches this provider. - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-subagent-fork-in-process) — every accepted config field and its source declaration. -- [Fork children stay one-shot](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md) — why shipped compositions bind fork to one-shot. +- [Forked children preserve the parent request prefix](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md) — why continuable fork preserves the inherited request prefix. ----- @@ -143,7 +143,7 @@ Append-only; newly visible content follows the reusable request prefix and does These limits define when the backend is the wrong choice; they are current package constraints. - **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. -- **Fork lifecycle policy differs by composition** — the base bundle and ACP/headless examples use one-shot fork, while the CLI presets use continuable fork. Both keep the inherited prefix eligible for reuse because parent and child messaging definitions match byte for byte; explicit persona, tool filtering, generated-SDK, or route changes can still break equality. Rationale: the [cache-preserving fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md). +- **Prefix reuse depends on matching request inputs** — parent and child messaging definitions match byte for byte in both lifecycle modes; explicit persona, tool filtering, generated-SDK, or route changes can still break equality. Rationale: the [cache-preserving fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md). - **Shipped fork tools do not expose child LLM route selection** — they inherit the parent's provider and model so the copied history remains eligible for KV Cache reuse. Route selection stays disabled until a change can preserve reuse or expose a bounded recomputation cost; the [model-selected route Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md) owns that restriction. diff --git a/packages/subagent/subagent-fork-in-process/README.zh.md b/packages/subagent/subagent-fork-in-process/README.zh.md index c1098825dc..0359ee2083 100644 --- a/packages/subagent/subagent-fork-in-process/README.zh.md +++ b/packages/subagent/subagent-fork-in-process/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" ### 生命周期绑定 -base 组合包与 ACP(Agent Client Protocol)/headless 示例在委派工具上把本提供方绑定为 `backgroundMode: one-shot`,CLI(命令行界面)预设则选择 `continuable`。两者都保留继承的请求前缀:父级与子级获得定义和顺序相同的消息工具,可继续子级的父级 ID 与返回指导位于继承历史之后的初始用户任务中(见[保持 fork 缓存的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md))。 +base 组合包与完整 CLI(命令行界面)预设在委派工具上把本提供方绑定为 `backgroundMode: continuable`;仍支持显式前台调用与一次性组合。两者都保留继承的请求前缀:父级与子级获得定义和顺序相同的消息工具,可继续子级的父级 ID 与返回指导位于继承历史之后的初始用户任务中(见[保持 fork 缓存的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md))。 @@ -93,14 +93,14 @@ base 组合包与 ACP(Agent Client Protocol)/headless 示例在委派工具 ## 进一步探索 -当包级约定不够用时阅读以下页面;它们从共享 subagent 模型进入兄弟后端,以及一次性绑定的设计证据。 +当包级约定不够用时阅读以下页面;它们从共享 subagent 模型进入兄弟后端,以及保留继承请求前缀的设计证据。 - [Subagent 子系统](../../../docs/subsystems/subagent.zh.md)——启动请求、结果、提供方约定与进程内深度和初始内容。 - [dsh-subagent-in-process-driver](../subagent-in-process-driver/README.zh.md)——本后端调用的共享运行驱动器。 - [dsh-subagent-spawn-in-process](../subagent-spawn-in-process/README.zh.md)——全新子级的兄弟后端。 - [dsh-tool-subagent](../tool-subagent/README.zh.md)——指向该提供方的面向模型委派工具。 - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-subagent-fork-in-process)——每个受支持配置字段及其源声明。 -- [fork 保持 one-shot](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md)——随附组合为何把 fork 绑定为 one-shot。 +- [Fork child 保留 parent 请求前缀](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md)——可继续 fork 为何保留继承的请求前缀。 ----- @@ -143,7 +143,7 @@ fork 会把保留的已完成历史复制到子 agent 的请求中,子 agent 这些限制说明何时选择该后端是错误的;它们是当前包约束。 - **初始内容是一次性快照**——子 agent 只能看到 fork 时父级已完成的轮次,看不到父级此后记录的任何内容;不会实时共享上下文。 -- **fork 生命周期策略因组合而异**——base 组合包与 ACP/headless 示例使用一次性 fork,CLI 预设使用可继续 fork。两者都因父级与子级的消息定义逐字节相同而让继承前缀保持可复用;显式 persona、工具过滤、生成 SDK 或路由变化仍可破坏相等性。理由见[保持 fork 缓存的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md)。 +- **前缀复用取决于请求输入是否匹配**——两种生命周期模式下,父级与子级的消息定义都逐字节相同;显式 persona、工具过滤、生成 SDK 或路由变化仍可破坏相等性。理由见[保持 fork 缓存的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md)。 - **随附 fork 工具不公开子级 LLM(大语言模型)路由选择**——它们继承父级提供方与模型,使复制的历史仍有资格复用 KV Cache。在某项改动能保留复用或公开有界重算成本前,路由选择保持禁用;[模型选择路由 Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md)说明这项限制。 diff --git a/snapshots/sdk/text-turn/system-prompt.expected.md b/snapshots/sdk/text-turn/system-prompt.expected.md index b1c3c8348f..30d66481ec 100644 --- a/snapshots/sdk/text-turn/system-prompt.expected.md +++ b/snapshots/sdk/text-turn/system-prompt.expected.md @@ -24,4 +24,6 @@ Use goal tools for one long-running completion objective in the current session. Use 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. +Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + Your working directory is {{cwd}}. diff --git a/snapshots/sdk/text-turn/tool-schemas.expected.json b/snapshots/sdk/text-turn/tool-schemas.expected.json index c5b2aedeba..c68f6a52b1 100644 --- a/snapshots/sdk/text-turn/tool-schemas.expected.json +++ b/snapshots/sdk/text-turn/tool-schemas.expected.json @@ -360,7 +360,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). 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 its result, not its intermediate steps. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_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). 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 its result, not its intermediate steps. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` steers the child's nearest step while it is running and starts a turn while it is idle. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -374,7 +374,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." } }, "required": [ From 508aee8ae5d5400c898aa61adcf7bfac9725a717 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 17 Sep 2026 11:02:59 +0800 Subject: [PATCH 004/402] test(subagent): refresh inherited fork defaults in SDK snapshots --- snapshots/sdk/bash-tool/system-prompt.expected.md | 2 ++ snapshots/sdk/bash-tool/tool-schemas.expected.json | 4 ++-- .../sdk/subagent-dsh-sdk-diagnostic/system-prompt.expected.md | 2 ++ .../subagent-dsh-sdk-diagnostic/tool-schemas.expected.json | 4 ++-- .../system-prompt.1.expected.md | 2 ++ .../subagent-dsh-sdk-dynamic-route/system-prompt.expected.md | 2 ++ .../tool-schemas.1.expected.json | 4 ++-- .../subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json | 4 ++-- 8 files changed, 16 insertions(+), 8 deletions(-) diff --git a/snapshots/sdk/bash-tool/system-prompt.expected.md b/snapshots/sdk/bash-tool/system-prompt.expected.md index b1c3c8348f..30d66481ec 100644 --- a/snapshots/sdk/bash-tool/system-prompt.expected.md +++ b/snapshots/sdk/bash-tool/system-prompt.expected.md @@ -24,4 +24,6 @@ Use goal tools for one long-running completion objective in the current session. Use 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. +Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + Your working directory is {{cwd}}. diff --git a/snapshots/sdk/bash-tool/tool-schemas.expected.json b/snapshots/sdk/bash-tool/tool-schemas.expected.json index c5b2aedeba..c68f6a52b1 100644 --- a/snapshots/sdk/bash-tool/tool-schemas.expected.json +++ b/snapshots/sdk/bash-tool/tool-schemas.expected.json @@ -360,7 +360,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). 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 its result, not its intermediate steps. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_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). 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 its result, not its intermediate steps. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` steers the child's nearest step while it is running and starts a turn while it is idle. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -374,7 +374,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." } }, "required": [ diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/system-prompt.expected.md b/snapshots/sdk/subagent-dsh-sdk-diagnostic/system-prompt.expected.md index 8bfa61c0ca..e9fdec9bef 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/system-prompt.expected.md +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/system-prompt.expected.md @@ -26,4 +26,6 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. +Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + Your working directory is {{cwd}}. diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json b/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json index a7039c0b86..f7bbe2b8c0 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json @@ -414,7 +414,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). 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 its result, not its intermediate steps. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_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). 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 its result, not its intermediate steps. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` steers the child's nearest step while it is running and starts a turn while it is idle. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -428,7 +428,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." } }, "required": [ diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/system-prompt.1.expected.md b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/system-prompt.1.expected.md index e5c348ee4f..65b113c7ef 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/system-prompt.1.expected.md +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/system-prompt.1.expected.md @@ -25,3 +25,5 @@ Use goal tools for one long-running completion objective in the current session. Use 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. Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/system-prompt.expected.md b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/system-prompt.expected.md index e85ee91f12..3c35e1a75e 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/system-prompt.expected.md +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/system-prompt.expected.md @@ -24,4 +24,6 @@ Use goal tools for one long-running completion objective in the current session. Use 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. +Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + Your working directory is {{cwd}}. diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json index a7ea35a6d4..a702b37ed6 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json @@ -364,7 +364,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). 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 its result, not its intermediate steps. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_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). 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 its result, not its intermediate steps. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` steers the child's nearest step while it is running and starts a turn while it is idle. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -378,7 +378,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." } }, "required": [ diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json index af4dc0cb93..2c637aeb72 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json @@ -389,7 +389,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). 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 its result, not its intermediate steps. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_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). 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 its result, not its intermediate steps. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` steers the child's nearest step while it is running and starts a turn while it is idle. Set `run_in_background: false` only when your next action depends on receiving the result.", "parameters": { "type": "object", "properties": { @@ -403,7 +403,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." } }, "required": [ From 3d852a1cea5d294462760b85c097238e851d7915 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 17 Sep 2026 14:54:10 +0800 Subject: [PATCH 005/402] refactor(subagent): unify local and external activation lifecycles --- ...8-10-fork-children-stay-one-shot.i18n.yaml | 4 +- .../2026-08-10-fork-children-stay-one-shot.md | 2 + ...26-08-10-fork-children-stay-one-shot.zh.md | 2 + ...01-parent-owned-subagent-catalog.i18n.yaml | 4 +- ...026-09-01-parent-owned-subagent-catalog.md | 2 + ...-09-01-parent-owned-subagent-catalog.zh.md | 2 + ...-agents-join-their-parent-preset.i18n.yaml | 4 +- ...0-child-agents-join-their-parent-preset.md | 2 +- ...hild-agents-join-their-parent-preset.zh.md | 2 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 2 + .../2026-06-21-subagent-capability-seam.zh.md | 2 + .../2026-07-05-dynamic-workflows.i18n.yaml | 4 +- .../feature/2026-07-05-dynamic-workflows.md | 2 +- .../2026-07-05-dynamic-workflows.zh.md | 2 +- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 2 + ...8-continuable-subagent-conversations.zh.md | 2 + ...ned-subagent-settlement-delivery.i18n.yaml | 4 +- ...ager-owned-subagent-settlement-delivery.md | 2 + ...r-owned-subagent-settlement-delivery.zh.md | 2 + ...-continuable-activation-capacity.i18n.yaml | 4 +- ...6-09-15-continuable-activation-capacity.md | 2 + ...9-15-continuable-activation-capacity.zh.md | 2 + ...-17-unified-subagent-activations.i18n.yaml | 6 + ...2026-09-17-unified-subagent-activations.md | 43 + ...6-09-17-unified-subagent-activations.zh.md | 43 + .../headless/tests/headless.expected.e2e.ts | 6 +- .../subagent-dsh-sdk-release-on-job-output.ts | 21 - apps/web/tests/auto-review-child.overlay.yml | 9 - apps/web/tests/shipped-composition.e2e.ts | 117 +-- .../tests/sidebar-subagent-activity.e2e.ts | 3 +- apps/web/tests/subagent-conversation.e2e.ts | 3 +- apps/web/tests/subagent-interrupt-ui.e2e.ts | 3 +- apps/web/tests/subagent-interrupt.e2e.ts | 3 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 15 +- docs/capability-seams.zh.md | 15 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 23 +- docs/config-catalog.zh.md | 25 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 12 +- docs/event-producer-consumer.zh.md | 12 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 43 +- docs/module-graph.zh.md | 43 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 251 ++++- docs/persistence-catalog.zh.md | 251 ++++- ...17-external-subagent-activations.i18n.yaml | 6 + ...026-09-17-external-subagent-activations.md | 52 + ...-external-subagent-activations.schema.json | 258 +++++ ...-09-17-external-subagent-activations.zh.md | 52 + .../historical-formats/README.i18n.yaml | 4 +- .../historical-formats/README.md | 2 +- .../historical-formats/README.zh.md | 2 +- docs/persistence-schema.json | 980 +++++++++++++++++- docs/subsystems/jobs.i18n.yaml | 4 +- docs/subsystems/jobs.md | 3 +- docs/subsystems/jobs.zh.md | 3 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 339 +++--- docs/subsystems/subagent.zh.md | 343 +++--- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 22 +- docs/tool-catalog.zh.md | 22 +- packages/acp/acp/src/session.ts | 16 +- packages/acp/acp/tests/dispose.spec.ts | 8 +- packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 2 - packages/bundle/headless/package.json | 11 +- packages/bundle/headless/src/index.ts | 6 +- .../bundle/headless/tests/headless.spec.ts | 42 +- packages/bundle/headless/tsconfig.json | 3 + packages/bundle/web-app/cordis.patch.yml | 4 +- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 6 +- packages/client/ui-subagent/README.zh.md | 4 +- .../src/client/SubagentHeaderLineage.tsx | 4 +- .../client/ui-subagent/src/client/locales.ts | 2 + .../tests/conversation-ui.client.spec.tsx | 14 + .../tests/serial-listener-review.spec.ts | 2 +- .../core/session/src/known-event-types.ts | 2 + .../experimental/agent-team/src/roster.ts | 10 +- .../agent-team/tests/persistence.spec.ts | 3 +- .../agent-team/tests/team.spec.ts | 33 +- .../auto-review/tests/auto-review.spec.ts | 1 - .../tool-agent-team/tests/tool-team.spec.ts | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 69 +- packages/jobs/jobs-local/tests/jobs.spec.ts | 13 +- packages/jobs/jobs/src/types.ts | 3 +- packages/jobs/jobs/tests/invariant.spec.ts | 8 +- packages/jobs/tool-jobs/README.i18n.yaml | 4 +- packages/jobs/tool-jobs/README.md | 4 +- packages/jobs/tool-jobs/README.zh.md | 4 +- .../jobs/tool-jobs/tests/tool-jobs.spec.ts | 40 +- .../presets/cordis/agent.cordis.yml | 4 - .../editing-cordis-compositions/SKILL.md | 4 +- .../presets/ptc/agent.cordis.yml | 4 - .../presets/standard/agent.cordis.yml | 4 - .../server/tests/built-scope-carrier.e2e.ts | 35 +- packages/sdk/server/tests/server.spec.ts | 35 +- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 1 - packages/subagent/README.zh.md | 1 - .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 6 +- packages/subagent/subagent-acp/README.zh.md | 6 +- .../subagent-acp/tests/subagent-acp.e2e.ts | 5 +- .../subagent-acp/tests/subagent-acp.spec.ts | 108 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 17 +- .../subagent-claude-code/README.zh.md | 17 +- .../fixtures/loader/claude-code.patch.yml | 4 - .../tests/loader-composition.e2e.ts | 8 +- .../tests/real-deepseek.e2e.ts | 3 +- .../tests/real-product.spec.ts | 13 +- .../tests/subagent-claude-code.spec.ts | 68 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 17 +- packages/subagent/subagent-codex/README.zh.md | 17 +- .../tests/fixtures/loader/codex.patch.yml | 3 - .../tests/loader-composition.e2e.ts | 6 +- .../subagent-codex/tests/real-deepseek.e2e.ts | 3 +- .../subagent-codex/tests/real-product.spec.ts | 23 +- .../tests/subagent-codex.spec.ts | 73 +- .../subagent-dsh-sdk/README.i18n.yaml | 4 +- packages/subagent/subagent-dsh-sdk/README.md | 10 +- .../subagent/subagent-dsh-sdk/README.zh.md | 10 +- .../fixtures/loader/scoped-tool-subagent.ts | 4 - .../tests/subagent-dsh-sdk.spec.ts | 81 +- .../subagent-fork-in-process/README.i18n.yaml | 4 +- .../subagent-fork-in-process/README.md | 13 +- .../subagent-fork-in-process/README.zh.md | 13 +- .../subagent-fork-in-process/package.json | 2 - .../subagent-fork-in-process/src/index.ts | 11 - .../tests/multi-subagent.spec.ts | 12 +- .../tests/subagent-fork-in-process.spec.ts | 10 +- .../subagent-fork-in-process/tsconfig.json | 3 - .../README.i18n.yaml | 6 - .../subagent-in-process-driver/README.md | 177 ---- .../subagent-in-process-driver/README.zh.md | 177 ---- .../subagent-in-process-driver/package.json | 61 -- .../subagent-in-process-driver/src/index.ts | 238 ----- .../tests/subagent-in-process-driver.spec.ts | 415 -------- .../subagent-in-process-driver/tsconfig.json | 36 - .../README.i18n.yaml | 4 +- .../subagent-spawn-in-process/README.md | 13 +- .../subagent-spawn-in-process/README.zh.md | 13 +- .../subagent-spawn-in-process/package.json | 5 +- .../subagent-spawn-in-process/src/index.ts | 9 - .../tests/harness.ts | 16 +- .../tests/subagent-spawn-in-process.spec.ts | 31 +- .../subagent-spawn-in-process/tsconfig.json | 3 - packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 41 +- packages/subagent/subagent/README.zh.md | 41 +- packages/subagent/subagent/package.json | 14 +- .../subagent/src/activation-driver.ts | 238 +++++ packages/subagent/subagent/src/catalog.ts | 28 +- packages/subagent/subagent/src/child-agent.ts | 4 +- .../subagent/src/continuation-activation.ts | 271 +++-- .../subagent/src/continuation-messages.ts | 21 +- .../subagent/subagent/src/continuation.ts | 134 ++- .../subagent/subagent/src/control-types.ts | 11 +- packages/subagent/subagent/src/control.ts | 8 +- .../subagent/subagent/src/external-records.ts | 185 ++++ packages/subagent/subagent/src/inbox.ts | 19 +- packages/subagent/subagent/src/index.ts | 167 ++- packages/subagent/subagent/src/invariant.ts | 2 +- packages/subagent/subagent/src/lifecycle.ts | 96 +- .../subagent/subagent/src/list-children.ts | 194 +++- .../subagent/subagent/src/run-settlement.ts | 75 -- .../src/structured.ts | 10 +- packages/subagent/subagent/src/types.ts | 89 +- .../subagent/tests/activation-driver.spec.ts | 47 + .../tests/activation-policy.spec.ts} | 60 +- .../tests/activation-presets.spec.ts} | 26 +- .../subagent/tests/child-agent.spec.ts | 16 +- .../tests/continuation-inheritance.spec.ts | 18 +- .../continuation-messages-adapter.spec.ts | 5 +- .../subagent/tests/continuation.spec.ts | 650 ++++++------ .../tests/external-activation-helpers.ts | 76 ++ .../tests/external-activation.spec.ts | 244 +++++ .../subagent/tests/external-lifecycle.spec.ts | 117 +++ .../subagent/tests/external-records.spec.ts | 321 ++++++ .../plugins/preset-tool.js | 0 .../presets/coding/agent.cordis.yml | 0 .../presets/reviewing/agent.cordis.yml | 0 .../subagent/tests/list-children.spec.ts | 32 +- .../subagent/tests/local-activation.ts | 57 + .../subagent/tests/run-settlement.spec.ts | 94 -- .../subagent/subagent/tests/service.spec.ts | 132 ++- .../tests/structured.spec.ts | 186 +++- packages/subagent/subagent/tsconfig.json | 3 - .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 11 +- .../tool-subagent-control/README.zh.md | 11 +- .../tool-subagent-control/src/index.ts | 4 +- .../tool-subagent-control/src/list-agents.ts | 25 +- .../tests/list-agents.spec.ts | 69 +- .../tests/tool-subagent-control.spec.ts | 42 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 50 +- packages/subagent/tool-subagent/README.zh.md | 50 +- packages/subagent/tool-subagent/package.json | 4 - packages/subagent/tool-subagent/src/index.ts | 308 +----- .../subagent/tool-subagent/tests/harness.ts | 41 +- .../tests/model-selection-settings.spec.ts | 5 - .../tests/model-selection.spec.ts | 23 +- .../tests/scripted-provider.spec.ts | 23 +- .../tool-subagent/tests/scripted-provider.ts | 11 +- .../tool-subagent/tests/tool-subagent.spec.ts | 853 ++------------- packages/subagent/tool-subagent/tsconfig.json | 3 - .../session-snapshot/README.i18n.yaml | 4 +- .../test-support/session-snapshot/README.md | 2 +- .../session-snapshot/README.zh.md | 2 +- .../fixtures/subagent-settlement-barrier.ts | 19 + packages/workflow/tool-ralph/package.json | 2 +- .../tool-ralph/tests/integration.spec.ts | 34 +- .../workflow/workflow-ptc/README.i18n.yaml | 4 +- packages/workflow/workflow-ptc/README.md | 4 +- packages/workflow/workflow-ptc/README.zh.md | 4 +- packages/workflow/workflow-ptc/package.json | 1 + packages/workflow/workflow-ptc/src/host.ts | 27 +- .../workflow-ptc/tests/built-runtime.e2e.ts | 6 +- .../workflow/workflow-ptc/tests/host.spec.ts | 75 +- .../workflow-ptc/tests/integration.spec.ts | 28 +- packages/workflow/workflow-ptc/tests/setup.ts | 8 +- .../workflow-ptc/tests/workflow-ptc.spec.ts | 16 +- pnpm-lock.yaml | 128 +-- python/sdk-runtime/package.json | 1 - scripts/doc-standard.spec.ts | 1 - .../python-snapshot-workflow-order.mjs | 33 +- scripts/gen-cordis-catalog.ts | 4 +- scripts/gen-doc-graphs.ts | 8 +- scripts/gen-tool-catalog.ts | 6 +- .../python-snapshot-workflow-order.spec.ts | 45 +- scripts/smoke-python-runtime.py | 31 +- .../advanced/result.json | 477 +++++---- .../advanced/session.1.v3.jsonl | 2 +- .../advanced/session.2.v3.jsonl | 2 +- .../advanced/session.v3.jsonl | 13 +- scripts/type-equiv.manifest.json | 15 +- snapshots/acp/escalation-approved/cordis.yml | 11 +- snapshots/sdk/bash-tool/cordis.yml | 2 - .../sdk/bash-tool/system-prompt.expected.md | 4 +- .../sdk/bash-tool/tool-schemas.expected.json | 12 +- .../sdk/multi-turn/writer.expected.jsonl | 4 +- snapshots/sdk/sdk.snapshot.ts | 34 +- snapshots/sdk/serial-created/cordis.yml | 2 - .../replay.override.json | 8 +- .../session.v3.jsonl | 12 +- .../system-prompt.1.expected.md | 4 +- .../tool-schemas.1.expected.json | 12 +- .../session.v3.jsonl | 21 +- .../system-prompt.1.expected.md | 4 +- .../tool-schemas.1.expected.json | 12 +- .../sdk/subagent-continuable/session.v3.jsonl | 8 +- .../system-prompt.1.expected.md | 4 +- .../tool-schemas.1.expected.json | 12 +- .../subagent-dsh-sdk-diagnostic/cordis.yml | 23 +- .../notifications.expected.jsonl | 50 +- .../session.v3.jsonl | 38 +- .../system-prompt.expected.md | 8 +- .../tool-schemas.expected.json | 30 +- .../subagent-dsh-sdk-dynamic-route/cordis.yml | 5 +- .../notifications.expected.jsonl | 17 +- .../session.1.v3.jsonl | 10 +- .../session.v3.jsonl | 11 +- .../system-prompt.1.expected.md | 4 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.1.expected.json | 16 +- .../tool-schemas.expected.json | 12 +- .../session.1.v3.jsonl | 12 +- .../subagent-fork-in-process/session.v3.jsonl | 13 +- .../sdk/subagent-list-agents/session.v3.jsonl | 8 +- .../system-prompt.1.expected.md | 4 +- .../tool-schemas.1.expected.json | 12 +- .../sdk/subagent-mixed/session.1.v3.jsonl | 12 +- .../sdk/subagent-mixed/session.2.v3.jsonl | 12 +- snapshots/sdk/subagent-mixed/session.v3.jsonl | 24 +- .../subagent-send-message/session.v3.jsonl | 8 +- .../system-prompt.1.expected.md | 4 +- .../tool-schemas.1.expected.json | 12 +- .../notifications.expected.jsonl | 32 +- .../runtime.cordis.yml | 4 + .../session.1.v3.jsonl | 14 +- .../session.v3.jsonl | 11 +- .../subagent-spawn-in-process/snapshot.yml | 2 +- snapshots/sdk/text-turn/cordis.yml | 2 - .../sdk/text-turn/system-prompt.expected.md | 4 +- .../sdk/text-turn/tool-schemas.expected.json | 12 +- .../session.1.v3.jsonl | 12 +- .../session.2.v3.jsonl | 12 +- .../session.v3.jsonl | 23 +- .../advanced-toolchain/session.1.v3.jsonl | 12 +- .../advanced-toolchain/session.2.v3.jsonl | 12 +- .../advanced-toolchain/session.v3.jsonl | 25 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 24 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 12 +- .../both-mode-turn/system-prompt.expected.md | 35 +- .../both-mode-turn/tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 24 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 35 +- .../tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 8 +- .../tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 8 +- .../tool-schemas.expected.json | 12 +- .../writer.expected.jsonl | 4 +- .../error-finish/writer.expected.jsonl | 2 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 12 +- snapshots/session/headless.snapshot.ts | 1 + .../lsp-definition/system-prompt.expected.md | 4 +- .../lsp-definition/tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 35 +- .../mcp-resources/system-prompt.expected.md | 4 +- .../mcp-resources/tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 8 +- .../packed-chunks/writer.expected.jsonl | 6 +- .../system-prompt.expected.md | 8 +- .../tool-schemas.expected.json | 24 +- .../plugin-manager/system-prompt.expected.md | 4 +- .../plugin-manager/tool-schemas.expected.json | 12 +- .../product-subagent-both/cordis.snapshot.yml | 4 - .../session/product-subagent-both/cordis.yml | 4 - .../product-subagent-both/snapshot.yml | 1 - .../system-prompt.expected.md | 40 + .../tool-schemas.expected.json | 36 +- .../cordis.snapshot.yml | 2 - .../session/product-subagent-codex/cordis.yml | 2 - .../system-prompt.expected.md | 8 +- .../tool-schemas.expected.json | 24 +- .../cordis.snapshot.yml | 1 - .../cordis.yml | 1 - .../replay.override.json | 242 ++++- .../session.v3.jsonl | 66 +- .../snapshot.yml | 1 - .../system-prompt.expected.md | 34 + .../tool-schemas.expected.json | 18 +- .../provider-cwd/system-prompt.expected.md | 4 +- .../provider-cwd/tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 35 +- .../system-prompt.expected.md | 35 +- .../ptc-python-turn/system-prompt.expected.md | 47 +- .../ptc-read-image/system-prompt.expected.md | 35 +- .../ptc-turn/system-prompt.expected.md | 35 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 12 +- .../session/ralph-loop/session.1.v3.jsonl | 4 +- .../session/ralph-loop/session.2.v3.jsonl | 4 +- snapshots/session/ralph-loop/session.v3.jsonl | 4 +- .../ralph-loop/system-prompt.1.expected.md | 4 +- .../ralph-loop/system-prompt.2.expected.md | 4 +- .../ralph-loop/system-prompt.expected.md | 4 +- .../ralph-loop/tool-schemas.1.expected.json | 12 +- .../ralph-loop/tool-schemas.2.expected.json | 12 +- .../ralph-loop/tool-schemas.expected.json | 12 +- .../read-image/system-prompt.expected.md | 4 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 12 +- snapshots/session/skill-load/session.v3.jsonl | 2 +- .../cordis.snapshot.yml | 6 +- .../subagent-acp-diagnostic/cordis.yml | 8 +- .../release-on-job-output.mjs | 31 - .../replay.override.json | 119 ++- .../subagent-acp-diagnostic/session.v3.jsonl | 30 +- .../settlement-order.mjs | 15 + .../subagent-acp-diagnostic/snapshot.yml | 1 - .../system-prompt.expected.md | 34 + .../tool-schemas.expected.json | 18 +- .../session.1.v3.jsonl | 16 +- .../session.v3.jsonl | 16 +- .../tool-schemas.expected.json | 12 +- .../cordis.snapshot.yml | 1 - .../subagent-depth-two-rejection/cordis.yml | 1 - .../replay.override.json | 112 +- .../session.1.v3.jsonl | 26 +- .../session.2.v3.jsonl | 16 +- .../session.v3.jsonl | 18 +- .../session.1.v3.jsonl | 16 +- .../session.v3.jsonl | 16 +- .../subagent-multi/replay.override.json | 43 + snapshots/session/subagent-multi/snapshot.yml | 2 + .../subagent-multi/writer.1.expected.jsonl | 12 +- .../subagent-multi/writer.2.expected.jsonl | 12 +- .../subagent-multi/writer.expected.jsonl | 25 +- .../subagent-parallel/execution-order.mjs | 69 ++ .../subagent-parallel/runtime.cordis.yml | 3 + .../subagent-parallel/session.1.v3.jsonl | 12 +- .../subagent-parallel/session.2.v3.jsonl | 12 +- .../subagent-parallel/session.v3.jsonl | 21 +- .../session.1.v3.jsonl | 1 + .../session.v3.jsonl | 7 +- .../session.1.v3.jsonl | 12 +- .../session.v3.jsonl | 16 +- .../subagent-tool-filter/cordis.snapshot.yml | 1 - .../session/subagent-tool-filter/cordis.yml | 1 - .../subagent-tool-filter/session.1.v3.jsonl | 16 +- .../subagent-tool-filter/session.v3.jsonl | 16 +- .../system-prompt.1.expected.md | 4 +- .../tool-schemas.1.expected.json | 12 +- .../system-prompt.expected.md | 8 +- .../tool-schemas.expected.json | 12 +- snapshots/session/text-turn/cordis.yml | 3 - .../text-turn/system-prompt.expected.md | 4 +- .../text-turn/tool-schemas.expected.json | 12 +- .../session/text-turn/writer.expected.jsonl | 2 +- .../web-fetch/system-prompt.expected.md | 4 +- .../web-fetch/tool-schemas.expected.json | 12 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 12 +- .../session/workflow-run/session.1.v3.jsonl | 4 +- .../session/workflow-run/session.v3.jsonl | 2 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 12 +- tsconfig.base.json | 1 - tsconfig.host.json | 1 - 438 files changed, 8052 insertions(+), 6410 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md create mode 100644 .agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md delete mode 100644 apps/cli/tests/profiles/sdk/fixtures/subagent-dsh-sdk-release-on-job-output.ts delete mode 100644 apps/web/tests/auto-review-child.overlay.yml create mode 100644 docs/persistence-changes/2026-09-17-external-subagent-activations.i18n.yaml create mode 100644 docs/persistence-changes/2026-09-17-external-subagent-activations.md create mode 100644 docs/persistence-changes/2026-09-17-external-subagent-activations.schema.json create mode 100644 docs/persistence-changes/2026-09-17-external-subagent-activations.zh.md delete mode 100644 packages/subagent/subagent-in-process-driver/README.i18n.yaml delete mode 100644 packages/subagent/subagent-in-process-driver/README.md delete mode 100644 packages/subagent/subagent-in-process-driver/README.zh.md delete mode 100644 packages/subagent/subagent-in-process-driver/package.json delete mode 100644 packages/subagent/subagent-in-process-driver/src/index.ts delete mode 100644 packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts delete mode 100644 packages/subagent/subagent-in-process-driver/tsconfig.json create mode 100644 packages/subagent/subagent/src/activation-driver.ts create mode 100644 packages/subagent/subagent/src/external-records.ts delete mode 100644 packages/subagent/subagent/src/run-settlement.ts rename packages/subagent/{subagent-in-process-driver => subagent}/src/structured.ts (94%) create mode 100644 packages/subagent/subagent/tests/activation-driver.spec.ts rename packages/subagent/{subagent-in-process-driver/tests/inheritance.spec.ts => subagent/tests/activation-policy.spec.ts} (88%) rename packages/subagent/{subagent-in-process-driver/tests/preset-inheritance.spec.ts => subagent/tests/activation-presets.spec.ts} (88%) create mode 100644 packages/subagent/subagent/tests/external-activation-helpers.ts create mode 100644 packages/subagent/subagent/tests/external-activation.spec.ts create mode 100644 packages/subagent/subagent/tests/external-lifecycle.spec.ts create mode 100644 packages/subagent/subagent/tests/external-records.spec.ts rename packages/subagent/{subagent-in-process-driver/tests/fixtures => subagent/tests/fixtures/activation-presets}/plugins/preset-tool.js (100%) rename packages/subagent/{subagent-in-process-driver/tests/fixtures => subagent/tests/fixtures/activation-presets}/presets/coding/agent.cordis.yml (100%) rename packages/subagent/{subagent-in-process-driver/tests/fixtures => subagent/tests/fixtures/activation-presets}/presets/reviewing/agent.cordis.yml (100%) create mode 100644 packages/subagent/subagent/tests/local-activation.ts rename packages/subagent/{subagent-in-process-driver => subagent}/tests/structured.spec.ts (80%) create mode 100644 packages/test-support/session-snapshot/tests/fixtures/subagent-settlement-barrier.ts create mode 100644 snapshots/sdk/subagent-spawn-in-process/runtime.cordis.yml create mode 100644 snapshots/session/product-subagent-both/system-prompt.expected.md create mode 100644 snapshots/session/product-subagent-result-diagnostic/system-prompt.expected.md delete mode 100644 snapshots/session/subagent-acp-diagnostic/release-on-job-output.mjs create mode 100644 snapshots/session/subagent-acp-diagnostic/settlement-order.mjs create mode 100644 snapshots/session/subagent-acp-diagnostic/system-prompt.expected.md create mode 100644 snapshots/session/subagent-multi/replay.override.json create mode 100644 snapshots/session/subagent-parallel/execution-order.mjs create mode 100644 snapshots/session/subagent-parallel/runtime.cordis.yml diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml index a5bde488e1..3726cb50ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.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-08-10-fork-children-stay-one-shot.md -2026-08-10-fork-children-stay-one-shot.md: 27f5a4101f2ffac494d923cc82a4a5759e6db30f -2026-08-10-fork-children-stay-one-shot.zh.md: 6f0b632744c9e5530c09fb31ff6e9cf6f18f53aa +2026-08-10-fork-children-stay-one-shot.md: c2854e4cda7ec7784d4ae1247ad869820e884745 +2026-08-10-fork-children-stay-one-shot.zh.md: 21149be41a5060343f154a32c747f98cf565dca6 diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md index 27f5a4101f..c2854e4cda 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md @@ -12,6 +12,8 @@ The earlier shipped composition avoided this mismatch by keeping forked children ## Decision +The shared activation entry point, external execution ownership, and caller-versus-parent result delivery are governed by [Unified subagent activations](../simplification/2026-09-17-unified-subagent-activations.md). This record retains the independent rationale described below. + The model-facing `send_message` tool is registered globally for every Agent in a composition. A continuable forked child therefore receives the same tool name, description, schema, and ordering as its parent. Its initial task is appended after the inherited Session seed, and the task includes the direct parent id plus guidance to return results with `send_message({ agent_id, message })` when that tool is visible to the child. The base bundle and the `cordis`, `standard`, and `ptc` CLI presets bind fork to the continuable lifecycle. Base-backed profiles, including headless, inherit that default. `ForkInProcessProvider.prepareContinuable()` and `ctx.subagents.startContinuable()` preserve the same request-prefix behavior across these compositions. diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md index 6f0b632744..21149be41a 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md @@ -12,6 +12,8 @@ fork 与 spawn 的差异在于:fork 会用 parent 已完成轮次的前缀作 ## 决策 +共享 activation 入口、外部执行所有权,以及调用方与父级之间的结果投递选择由[统一 subagent activation](../simplification/2026-09-17-unified-subagent-activations.zh.md) 决策拥有。本记录保留下述独立理由。 + 面向模型的 `send_message` 工具在组合中的每个 Agent 上全局注册。因此,可继续 fork child 获得与 parent 相同的工具名称、描述、schema 和顺序。其初始任务追加在继承的 Session 种子之后;当 child 可以看到该工具时,任务还会包含直接 parent id,以及使用 `send_message({ agent_id, message })` 返回结果的指引。 base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为可继续生命周期。基于 base 的 profile(包括 headless)继承这一默认值。`ForkInProcessProvider.prepareContinuable()` 与 `ctx.subagents.startContinuable()` 在这些组合中保持相同的请求前缀行为。 diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml index c09aaa9dcb..328ede2200 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.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-09-01-parent-owned-subagent-catalog.md -2026-09-01-parent-owned-subagent-catalog.md: 20e2dd035b41612592e7baeb55f89322dff02848 -2026-09-01-parent-owned-subagent-catalog.zh.md: 6ec33e8be8d4373ba98a9934178a67e207ffb0bb +2026-09-01-parent-owned-subagent-catalog.md: d179588ef2f1cfd9bc191b5dd52dd86272e2ac34 +2026-09-01-parent-owned-subagent-catalog.zh.md: f90263dd21e1be0a6f836f86424f3a54c714ca98 diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md index 20e2dd035b..d179588ef2 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md @@ -12,6 +12,8 @@ The child descriptor remains necessary for recovery and composition, but it cann ## Decision +The shared activation entry point, external execution ownership, and caller-versus-parent result delivery are governed by [Unified subagent activations](../simplification/2026-09-17-unified-subagent-activations.md). This record retains the independent rationale described below. + The parent Session's required `subagent/catalog` events are the persistent authority for direct-child discovery. Each event is one successful creation fact containing `childId`, `childCreatedAt`, mode, and the mode-discriminated label. Remote one-shot runs without a local Session remain outside this catalog. Invalid own facts, including unsupported payload versions, reject projection restoration because silently dropping a required fact would return an incomplete catalog. Creation publishes only successful facts. A one-shot run appends the catalog event after its provider returns a local child and before the run reaches its caller. A continuable run admits the initial prompt, appends the catalog event, then returns the child id. If admission or catalog append fails, creation fails and releases the activation; there is no compensating catalog event or rollback protocol. diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md index 6ec33e8be8..f90263dd21 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md @@ -12,6 +12,8 @@ child descriptor 对恢复与 composition 仍然必要,但它不能作为 disc ## 决策 +共享 activation 入口、外部执行所有权,以及调用方与父级之间的结果投递选择由[统一 subagent activation](../simplification/2026-09-17-unified-subagent-activations.zh.md) 决策拥有。本记录保留下述独立理由。 + parent Session 的 required `subagent/catalog` 事件是直接 child discovery 的持久化权威。每个事件都是一条成功创建事实,包含 `childId`、`childCreatedAt`、mode 与按 mode 区分的 label。没有本地 Session 的远程 one-shot run 不进入该目录。无效的自身 fact(包括不支持的 payload 版本)会使 projection 恢复失败,因为静默丢弃 required fact 会返回不完整的目录。 创建只发布成功事实。one-shot run 在 provider 返回本地 child 后、run 到达调用方前追加目录事件。continuable run 先准入初始 prompt,再追加目录事件,最后返回 child id。准入或目录追加失败时,创建失败并释放 activation;不存在补偿目录事件或 rollback 协议。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml index 622596ee8c..22806466c1 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.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-08-10-child-agents-join-their-parent-preset.md -2026-08-10-child-agents-join-their-parent-preset.md: 5321d99bad35cf26ebc0e7842dc68c2775bc384b -2026-08-10-child-agents-join-their-parent-preset.zh.md: f0d5b59c3787ef69c2c983da0c8ec0ff4a43e6e7 +2026-08-10-child-agents-join-their-parent-preset.md: 330476ba14ce4a6708367b75e6e4d347e7cd4f86 +2026-08-10-child-agents-join-their-parent-preset.zh.md: a8bfb3afd5c0814d1438b90ae1dd959f94b637d3 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md index 5321d99bad..330476ba14 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md @@ -44,7 +44,7 @@ Giving the child its parent's tools exposed a second defect the same agent-plane `packages/core/tools/tests/scoped.spec.ts` covers the restriction rule directly: a child's filter removes a tool it inherited from an ancestor scope, the child's own registrations survive its own filter, and an ancestor's restriction still reaches every scope nested inside it. -`packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, a `toolFilter` applied over the inherited preset tools, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header. +`packages/subagent/subagent/tests/activation-presets.spec.ts` asserts the model-visible result through `startActivation()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, a `toolFilter` applied over the inherited preset tools, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header. The assembled-transcript layer is the shipped Web composition's e2e rather than a keyless snapshot. Every runnable example this repo ships composes no preset roster, so the defect is not observable in the snapshot harness at all: a snapshot scenario would first need an example that mounts a roster AND delegates. The Web e2e boots the real `base` + `web-app` patch layers with both shipped presets, which is the assembled evidence the testing policy asks for; the Web browser lane's subagent goldens carry the visible consequence, since a child that records its preset now shows the preset badge its parent shows. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md index f0d5b59c37..a8bfb3afd5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md @@ -44,7 +44,7 @@ Status: implemented `packages/core/tools/tests/scoped.spec.ts` 直接覆盖该限制规则:子 agent 的过滤器能移除它从祖先作用域继承来的工具、子 agent 自身的注册在自己的过滤器下存活、祖先的限制仍作用于其内嵌套的每个作用域。 -`packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset、施加在继承来的 preset 工具之上的 `toolFilter`,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"。 +`packages/subagent/subagent/tests/activation-presets.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startActivation()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset、施加在继承来的 preset 工具之上的 `toolFilter`,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"。 组装记录这一层用的是真实 shipped Web 组装的 e2e,而不是无密钥快照。本仓库所有可运行 example 都不组装 preset roster,因此该缺陷在快照 harness 里根本不可观察:要做快照场景,得先有一个既挂载 roster 又发起委派的 example。Web e2e 启动的是真实的 `base` + `web-app` 补丁层与两个 shipped preset,这正是测试政策要求的组装证据;Web 浏览器 lane 的 subagent golden 承载了可见后果——记录了 preset 的子 agent 现在会显示与其父方相同的 preset 徽标。 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 1445f54bbb..784fed67ee 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 @@ -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-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: 6eedb3a4b03cd919aa8888853fac5fdaf3f74fe0 -2026-06-21-subagent-capability-seam.zh.md: 7e73fdc58f4b537ca2f6be481e359b267af31d96 +2026-06-21-subagent-capability-seam.md: 07c58086e2d0b45aebf0aa17e7bcccf7ecf0ad95 +2026-06-21-subagent-capability-seam.zh.md: 02df330d81ff8897078b21479f0cb2eecd6ebb33 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 6eedb3a4b0..07c58086e2 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 @@ -25,6 +25,8 @@ The bash seam ([capability seams](../architecture/2026-06-13-capability-seams.md ## Decision +The shared activation entry point, external execution ownership, and caller-versus-parent result delivery are governed by [Unified subagent activations](../simplification/2026-09-17-unified-subagent-activations.md). This record retains the independent rationale described below. + ### The three-package boundary A new package group `packages/subagent/`: 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 7e73fdc58f..02df330d81 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 @@ -25,6 +25,8 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.zh.md)) ## 决策 +共享 activation 入口、外部执行所有权,以及调用方与父级之间的结果投递选择由[统一 subagent activation](../simplification/2026-09-17-unified-subagent-activations.zh.md) 决策拥有。本记录保留下述独立理由。 + ### 由三类包构成的边界 新建包组 `packages/subagent/`: diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml index 8fe72e240d..b1119ef871 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.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-05-dynamic-workflows.md -2026-07-05-dynamic-workflows.md: c7245704b0734c1508fe7f001e6429f594ebdd1d -2026-07-05-dynamic-workflows.zh.md: a4502d7d6b3acf1aebc4a5b36caccf50859bab01 +2026-07-05-dynamic-workflows.md: 8b8259d8445a6bdbfd63afd2d58427c653f9d13f +2026-07-05-dynamic-workflows.zh.md: cad5440713ced3c7f3c6d8a5e40fff41f7f866d2 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md index c7245704b0..8b8259d844 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md @@ -38,7 +38,7 @@ For a top-level tool execution, the same consumer also writes the run and actual ### The foundation: structured output on the subagent seam -`SubagentStartRequest.outputSchema` is implemented by `dsh-subagent-in-process-driver` for both in-process backends. Each structured child receives its own scoped capture tool, instruction, and enforcement registrations on `child.ctx`; concurrent children can use different schemas without sharing mutable policy, and disposing the child removes the entire attachment. +`SubagentStartRequest.outputSchema` is implemented by `dsh-subagent` for both in-process backends. Each structured child receives its own scoped capture tool, instruction, and enforcement registrations on `child.ctx`; concurrent children can use different schemas without sharing mutable policy, and disposing the child removes the entire attachment. An output schema makes a schema-valid committed capture mandatory for successful child completion. The scoped runtime presents the capture tool and instruction, commits only a successful final outcome—including the enclosing `run_code` outcome for an SDK call—denies later side effects after capture becomes pending, and stops the child without another model step after commit. A validation failure remains a retryable tool error; clean completion without a committed capture settles as an error. diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md index a4502d7d6b..cad5440713 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -38,7 +38,7 @@ harness 可以通过 `dsh-tool-subagent` 将一个任务委派给一个子 agent ### 基础:subagent seam 上的结构化输出 -`SubagentStartRequest.outputSchema` 由 `dsh-subagent-in-process-driver` 为两个进程内后端实现。每个结构化子 agent 在 `child.ctx` 上获得自己的作用域捕获工具、指令和强制注册;并发子 agent 可以使用不同的 schema 而不共享可变策略,dispose 子 agent 时移除整个附件。 +`SubagentStartRequest.outputSchema` 由 `dsh-subagent` 为两个进程内后端实现。每个结构化子 agent 在 `child.ctx` 上获得自己的作用域捕获工具、指令和强制注册;并发子 agent 可以使用不同的 schema 而不共享可变策略,dispose 子 agent 时移除整个附件。 输出 schema 使一次 schema 有效的已提交捕获成为子 agent 成功完成的必要条件。作用域运行时呈现捕获工具和指令,仅提交成功的最终结果(包括 SDK 调用时外层 `run_code` 的结果),在捕获变为 pending 后拒绝后续副作用,并在提交后不再进行模型步骤即停止子 agent。校验失败仍是可重试的工具错误;没有已提交捕获的正常完成以错误结算。 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 1369b927e3..de10620237 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: a30966c4d614fda837bc2bac95f6b6e411de9791 -2026-07-28-continuable-subagent-conversations.zh.md: 59a02a211e0ca861f819a7524e4d69ad09acdbbc +2026-07-28-continuable-subagent-conversations.md: 9a130149601d16a896bef610f4652ce1488ffd59 +2026-07-28-continuable-subagent-conversations.zh.md: c76adf996079f62b8c6891d7a9c876325ff4b877 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 a30966c4d6..9a13014960 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 @@ -18,6 +18,8 @@ Parent Agents need to send later work to the same live child without changing it ## Decision +The shared activation entry point, external execution ownership, and caller-versus-parent result delivery are governed by [Unified subagent activations](../simplification/2026-09-17-unified-subagent-activations.md). This record retains the independent rationale described below. + A continuable subagent has one durable Session and at most one process-local Activation: ```text 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 59a02a211e..c76adf9960 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 @@ -18,6 +18,8 @@ parent Agent 还需要在不改变当前轮次的前提下,向同一个在线 ## 决策 +共享 activation 入口、外部执行所有权,以及调用方与父级之间的结果投递选择由[统一 subagent activation](../simplification/2026-09-17-unified-subagent-activations.zh.md) 决策拥有。本记录保留下述独立理由。 + 一个可继续 subagent 拥有一个持久化会话,并且至多拥有一个进程内激活: ```text diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml index c79578f47b..ef31b21854 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md -2026-08-06-manager-owned-subagent-settlement-delivery.md: 5553d4c06a6776f0ef025e19ae7f4f67fe277a56 -2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 6d7ee756ad531e123508e8667adc4e5f99c2b9ea +2026-08-06-manager-owned-subagent-settlement-delivery.md: 6e564e35b556adf293eb8697333adb7a7cd4059a +2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 02e813745caf72e5ddb972dd79f508560b14f7d3 diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md index 5553d4c06a..6e564e35b5 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md @@ -14,6 +14,8 @@ The signal already existed. `subagent/end` has carried `stopReason` and `lastAss ## Decision +The shared activation entry point, external execution ownership, and caller-versus-parent result delivery are governed by [Unified subagent activations](../simplification/2026-09-17-unified-subagent-activations.md). This record retains the independent rationale described below. + The continuation manager delivers the account itself, from inside the disposal transaction that ends the Activation. When a resident Activation settles, `notifySettlement()` resolves the child's durable direct parent and sends it one user-role message: the epoch's outcome as a sentence the parent can act on, then the text from the child's final assistant output, or a statement that it produced no closing text. Delivery is unconditional for every child whose id a caller actually received. It does not consult whether the child reported, and it keeps no bookkeeping that could make the promise conditional — that unconditionality is what lets `tool-subagent` promise a runtime notice containing `its outcome and any final assistant message`. A materialization rolled back before its first accepted message stays silent, because the caller was told that child was not established. diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md index 6d7ee756ad..02e813745c 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md @@ -14,6 +14,8 @@ Status: implemented ## 决策 +共享 activation 入口、外部执行所有权,以及调用方与父级之间的结果投递选择由[统一 subagent activation](../simplification/2026-09-17-unified-subagent-activations.zh.md) 决策拥有。本记录保留下述独立理由。 + 继续执行管理器自己投递这份记账,就在结束 Activation 的那笔 dispose 事务内部完成。 当驻留 Activation 结算时,`notifySettlement()` 解析该 child 持久化的直接父级,并向它发送一条用户角色消息:先是父级可据以行动的一句结果说明,然后是 child 最终 assistant 输出中的文本,或一句说明它没有产出收尾文本。对每个调用方真正拿到过 id 的 child,投递都是无条件的。它不查询 child 是否上报过,也不保留任何可能让这项承诺变成有条件的记账——正是这种无条件性,才让 `tool-subagent` 能够承诺一条包含 `its outcome and any final assistant message` 的运行时通知。在第一条消息被接受之前就回滚的物化保持静默,因为调用方已被告知该 child 未建立。 diff --git a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.i18n.yaml index f27ca4a2b6..4eac93b336 100644 --- a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.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-09-15-continuable-activation-capacity.md -2026-09-15-continuable-activation-capacity.md: bf7ca6d78d7a39a9c5d42dbc000de98706863cb5 -2026-09-15-continuable-activation-capacity.zh.md: 404c2c0f4eef9576670b51cd253b53ad0d4ef0d5 +2026-09-15-continuable-activation-capacity.md: 2e8e3393c63d1cdb1195573c2518514f05102da9 +2026-09-15-continuable-activation-capacity.zh.md: 517e3da7fae7566810a848b26430fe7d1b63610a diff --git a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md index bf7ca6d78d..2e8e3393c6 100644 --- a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md +++ b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md @@ -10,6 +10,8 @@ Depth limits bound nesting but permit wide concurrent delegation. Background Job ## Decision +The shared activation entry point, external execution ownership, and caller-versus-parent result delivery are governed by [Unified subagent activations](../simplification/2026-09-17-unified-subagent-activations.md). This record retains the independent rationale described below. + The subagent service configures `maxActiveSubagents`, defaulting to 8. Each live non-continuable parent owns one process-local pool, shared by reference through uninterrupted continuable parent links. The pool owner itself is excluded. One-shot runs and external-provider work do not enter this pool. A one-shot intermediate parent starts a separate pool for its continuable children; cross-one-shot capacity inheritance is deferred. Delegation depth remains independently configured. The Activation registry reserves a unique slot before fresh or cold-resume reconstruction yields. The materialization owns rollback until the Activation owns the slot; unpublished rollback and failed materialization may both release the same token safely. Handle disposal precedes release, which precedes parent settlement notification. Sending to an existing Activation reuses its slot. diff --git a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md index 404c2c0f4e..517e3da7fa 100644 --- a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md @@ -10,6 +10,8 @@ Status: implemented ## Decision +共享 activation 入口、外部执行所有权,以及调用方与父级之间的结果投递选择由[统一 subagent activation](../simplification/2026-09-17-unified-subagent-activations.zh.md) 决策拥有。本记录保留下述独立理由。 + Subagent 服务通过 `maxActiveSubagents` 配置容量,默认值为 8。每个存活的非可续接父代理拥有一个进程内池,通过连续的可续接父子关系按引用共享。池的所有者自身不计入。一次性运行和外部提供方工作不进入此池。一次性中间父代理为其可续接子代理建立独立的池;跨一次性代理的容量池继承暂不实现。委派深度仍独立配置。 Activation registry 在新建或冷恢复重建首次让出执行前预占唯一名额。在 Activation 接管名额前,由 materialization 负责回滚;未发布回滚和失败的 materialization 可以安全地释放同一个 token。handle 释放先于名额归还,名额归还先于父代理完成通知。向已有 Activation 发送消息复用其名额。 diff --git a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.i18n.yaml new file mode 100644 index 0000000000..8b97314748 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.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-09-17-unified-subagent-activations.md +2026-09-17-unified-subagent-activations.md: efb656bfa666bd5f8f1d671a1fe5a2bf8e714dbd +2026-09-17-unified-subagent-activations.zh.md: 93963236ccb4c69a03f8d4eee78384f4f3d28274 diff --git a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md new file mode 100644 index 0000000000..efb656bfa6 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md @@ -0,0 +1,43 @@ +# Agent Note: Unified subagent activations + +Status: implemented + +English | [中文](2026-09-17-unified-subagent-activations.zh.md) + +## Problem + +Local one-shot execution and continuable residency separately owned child creation, cancellation, structured capture, results, and cleanup. Foreground and Job-backed tool paths added more ownership paths. Workflow code still needed to await a result, while external backends needed one process execution without acquiring a multi-turn protocol. + +## Decision + +`startActivation()` is the consumer entry point for every provider. A published handle exposes child identity, a result promise, and disposal of that exact activation. The caller signal cancels unpublished creation only. Result readiness and subtree quiescence remain separate: awaiting a useful result does not imply that owned descendants or process cleanup have finished. + +Local spawn and fork providers contribute only `prepareContinuable()` data; the subagent service owns Agent creation and residency. The standalone in-process driver package is removed. Structured tools, validation, instructions, and terminal guards attach to one activation and close with it. After capture, that activation rejects further input; cold resume reconstructs an ordinary conversation without the previous schema. + +ACP, DSH SDK, Codex, and Claude Code retain their existing single-execution provider adapters. They join the same activation capacity and ownership graph without accepting follow-up messages or fabricating a local child Session. Parent-owned external start and end events retain their identities and results for discovery after disposal. + +The model-facing tool always returns a background child id and promises the manager's completion notice. It has no foreground switch or Job integration. Workflows select caller delivery, await the activation result, and dispose the handle before finishing; they add neither a completion notice nor initial return guidance to the parent/child exchange. Headless completion waits for its own child tree and subsequent parent turns. + +### Retained decisions + +The [named-provider seam](../feature/2026-06-21-subagent-capability-seam.md), [continuable residency](../feature/2026-07-28-continuable-subagent-conversations.md), [fork request prefix](../architecture/2026-08-10-fork-children-stay-one-shot.md), [settlement delivery ordering](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.md), [parent-owned catalog](../architecture/2026-09-01-parent-owned-subagent-catalog.md), and [activation capacity](../feature/2026-09-15-continuable-activation-capacity.md) retain their independent rationale. This decision owns the shared entry point, external participation, and caller-versus-parent result delivery. Those records are partially superseded, not candidates for consolidation or archival. + +Released Session generations remain immutable. Historical one-shot descriptors stay readable; removing current execution paths does not justify rewriting or discarding durable history. + +## Alternatives considered + +**Keep one-shot local execution for synchronous workflows.** Awaiting a result is a consumer requirement. A second Agent lifecycle duplicates cancellation and cleanup solely to supply a promise, which the activation can supply directly. + +**Add multi-turn support to every external backend.** This expands backend protocols, recovery, and authenticated routing without being necessary for shared ownership. The adapters retain their existing execution capability. + +**Use Jobs for all background children.** Jobs add a second work registry and cancellation authority beside activation residency and the Agent inbox. Child discovery and completion already belong to the subagent service. + +**Send every result to the parent.** A workflow already consumes and presents its child result. Another wake adds duplicate context and can disturb a parent that is awaiting the workflow tool. Delivery is an explicit consumer choice; the model-facing tool's notice remains unconditional. + +## Consequences + +One manager controls admission, cancellation, ownership, and resource release across providers. Workflow result collection remains synchronous at its API, while model delegation leaves the parent's tool step promptly. External children gain shared accounting and retained results, but gain no continuation. Restoring foreground or Job-backed local execution would require a concrete capability that result collection through an activation cannot provide. + +## Verification + +Focused tests cover local spawn/fork inheritance, structured capture and schema-free cold resume, explicit cancellation, external execution records, result-versus-disposal ordering, workflow collection without parent notices, and headless child-tree completion. Existing replay generations are preserved; updated recorded-session cases cover the current model-visible tool and notice behavior. diff --git a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md new file mode 100644 index 0000000000..93963236cc --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 统一 subagent activation + +Status: implemented + +[English](2026-09-17-unified-subagent-activations.md) | 中文 + +## 问题 + +本地一次性执行与可继续驻留分别拥有子级创建、取消、结构化捕获、结果和清理。前台与 Job 支持的工具路径又增加了所有权路径。工作流仍需等待结果,而外部后端需要一次进程执行,无需获得多轮协议。 + +## 决策 + +`startActivation()` 是所有提供方的消费者入口。已发布句柄暴露子级身份、结果 promise 和对应 activation 的资源释放。调用方信号仅取消未发布的创建。结果就绪与子树完全停稳保持分离:等待有用结果并不表示所拥有的后代或进程清理已完成。 + +本地 spawn 和 fork 提供方只贡献 `prepareContinuable()` 数据,由 subagent 服务拥有 Agent 创建与驻留。独立进程内驱动器包被移除。结构化工具、校验、指令与终止保护附着于一次 activation,并随其关闭。捕获后该 activation 拒绝继续输入;冷恢复重建普通对话,不携带此前的 schema。 + +ACP、DSH SDK、Codex 与 Claude Code 保留现有单次执行适配器。它们加入相同的 activation 容量与所有权图,不接受后续消息,也不伪造本地子 Session。父级拥有的外部开始与结束事件保留身份和结果,供释放后的发现使用。 + +面向模型的工具始终返回后台子级 ID,并承诺管理器的完成通知。它没有前台开关或 Job 集成。工作流选择调用方投递,等待 activation 结果并在完成前释放句柄;它们不向父子交互添加完成通知或初始返回指导。Headless 完成流程等待其自身子树与后续父级轮次。 + +### 保留的决策 + +[具名提供方 seam](../feature/2026-06-21-subagent-capability-seam.zh.md)、[可继续驻留](../feature/2026-07-28-continuable-subagent-conversations.zh.md)、[fork 请求前缀](../architecture/2026-08-10-fork-children-stay-one-shot.zh.md)、[结算投递顺序](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md)、[父级拥有的目录](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 和 [activation 容量](../feature/2026-09-15-continuable-activation-capacity.zh.md) 保留各自独立理由。本决策拥有共享入口、外部执行参与以及调用方与父级之间的结果投递选择。这些记录仅被部分取代,不满足合并或归档条件。 + +已发布 Session 世代保持不可变。历史一次性描述符仍可读取;移除当前执行路径不构成改写或丢弃持久历史的理由。 + +## 考虑过的替代方案 + +**为同步工作流保留本地一次性执行。** 等待结果是消费者需求。仅为提供 promise 而维护第二套 Agent 生命周期会重复取消与清理;activation 可以直接提供该 promise。 + +**为所有外部后端增加多轮支持。** 这会扩展后端协议、恢复和经过授权的路由,却不是共享所有权的必要条件。适配器保留现有执行能力。 + +**让所有后台子级使用 Jobs。** Jobs 在 activation 驻留和 Agent 收件箱之外增加第二套工作注册表与取消权限。子级发现和完成已属于 subagent 服务。 + +**把每个结果都发送给父级。** 工作流已经消费并展示子级结果;再次唤醒会重复上下文,也可能干扰正在等待工作流工具的父级。投递是显式消费者选择,面向模型工具的通知仍无条件提供。 + +## 后果 + +同一个管理器控制所有提供方的准入、取消、所有权和资源释放。工作流 API 仍同步收集结果,模型委派则及时结束父级工具 step。外部子级获得共享计数与保留结果,但不获得继续执行能力。恢复前台或 Job 支持的本地执行,需要 activation 结果收集无法提供的具体能力。 + +## 验证 + +聚焦测试覆盖本地 spawn/fork 继承、结构化捕获与无 schema 冷恢复、显式取消、外部执行记录、结果与释放顺序、不发父级通知的工作流收集,以及 headless 子树完成。既有回放世代得到保留;更新的录制会话用例覆盖当前面向模型的工具与通知行为。 diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 62bccb3b29..5b722e6af5 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -748,12 +748,12 @@ describe('headless stream-json snapshots', () => { const parent = logs.find(log => typeof log.header.parentSession !== 'string') if (parent === undefined) throw new Error('Agent Teams snapshot did not persist its Lead') const rows = parseJsonl(parent.content) - const workflowChild = logs.find(log => parseJsonl(log.content).some(row => row.type === 'subagent/descriptor' - && (row.data as JsonObject).mode === 'one-shot')) + const workflowChild = logs.find(log => parseJsonl(log.content).some(row => row.type === 'user/message' + && JSON.stringify((row.data as JsonObject).content) === JSON.stringify([{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }]))) if (workflowChild === undefined) throw new Error('Team profile did not persist its workflow child') const workflowRows = parseJsonl(workflowChild.content) expect(workflowRows.find(row => row.type === 'subagent/descriptor')?.data) - .toMatchObject({ mode: 'one-shot', provider: 'spawn' }) + .toMatchObject({ mode: 'continuable', provider: 'spawn' }) expect(workflowRows.filter(row => row.type === 'user/message' && ((row.data as JsonObject).source as JsonObject).kind === 'user').map(row => row.data)) .toEqual([expect.objectContaining({ content: [{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }] })]) diff --git a/apps/cli/tests/profiles/sdk/fixtures/subagent-dsh-sdk-release-on-job-output.ts b/apps/cli/tests/profiles/sdk/fixtures/subagent-dsh-sdk-release-on-job-output.ts deleted file mode 100644 index 554f62dcb1..0000000000 --- a/apps/cli/tests/profiles/sdk/fixtures/subagent-dsh-sdk-release-on-job-output.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** Release the gated background SDK child only after job_output starts waiting. */ - -import { writeFileSync } from 'node:fs' -import type { Context } from '@deepseek-ai/cordis' - -export const name = 'subagent-dsh-sdk-release-on-job-output' -export const inject = ['tools'] - -/** - * Register the test-only execution-order barrier. - * @param ctx - parent runtime context carrying the tool execution waterfall. - */ -export function apply(ctx: Context): void { - ctx.on('tools/execute', async (exec, next) => { - const delegated = next() - if (exec.name === 'job_output') { - writeFileSync('.dsh-sdk-background-release', 'release\n') - } - return delegated - }, { prepend: true }) -} diff --git a/apps/web/tests/auto-review-child.overlay.yml b/apps/web/tests/auto-review-child.overlay.yml deleted file mode 100644 index a44addc554..0000000000 --- a/apps/web/tests/auto-review-child.overlay.yml +++ /dev/null @@ -1,9 +0,0 @@ -# Add one one-shot binding beside the shipped continuable delegation tool so -# this integration lane covers both in-process lifecycle modes. -- insert: - - id: auto-review-one-shot-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent_one_shot - backgroundMode: one-shot diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index ac7ec67d49..c9adc12b7a 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -3,7 +3,7 @@ // producer-to-tool path. Browser scenarios in this lane own visual behavior. import { randomUUID } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -32,16 +32,15 @@ const FILE_REFERENCE_PROMPT = fileURLToPath(new URL( )) const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') const HEADLESS_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/headless/cordis.patch.yml') -const AUTO_CHILD_OVERLAY_PATH = join(REPO_ROOT, 'apps/web/tests/auto-review-child.overlay.yml') const AUTO_PROVIDER = 'shipped-auto-review-test' const AUTO_MODEL = 'same-route' const AUTO_CALL_ID = ToolCallId('shipped-auto-review-denied-delete') const AUTO_RAW_REASON = ` direct user authorized inspection only\nTEST_ONLY_SECRET_${'x'.repeat(16_384)} ` const AUTO_FINAL_TEXT = 'SHIPPED_AUTO_REVIEW_DENIAL_OBSERVED' -const AUTO_CHILD_ONE_SHOT = 'AUTO_CHILD_ONE_SHOT' +const AUTO_CHILD_AUTHORIZED = 'AUTO_CHILD_AUTHORIZED' const AUTO_CHILD_CONTINUABLE = 'AUTO_CHILD_CONTINUABLE' const AUTO_CHILD_ADJUSTED = 'AUTO_CHILD_ADJUSTED' -const AUTO_PARENT_ONE_SHOT = 'AUTO_PARENT_ONE_SHOT' +const AUTO_PARENT_AUTHORIZED = 'AUTO_PARENT_AUTHORIZED' const AUTO_PARENT_CONTINUABLE = 'AUTO_PARENT_CONTINUABLE' const AUTO_PARENT_ADJUST = 'AUTO_PARENT_ADJUST' @@ -135,7 +134,7 @@ interface ChildReviewObservation { } interface ChildScriptState { - readonly kind: 'one-shot' | 'continuable' + readonly kind: 'authorized' | 'continuable' phase: number } @@ -183,7 +182,7 @@ function topLevelText(options: GenerateOptions): string { ))).join('\n') } -/** Same-route scripts for real one-shot, continuable, and cold-resumed children. */ +/** Same-route scripts for fresh and cold-resumed children. */ class ShippedChildAutoAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] readonly reviews: ChildReviewObservation[] = [] @@ -194,7 +193,7 @@ class ShippedChildAutoAdapter extends LlmAdapter { constructor( private readonly sourcePath: string, - private readonly oneShotDeletePath: string, + private readonly authorizedDeletePath: string, private readonly continuableDeletePath: string, ) { super() @@ -229,7 +228,7 @@ class ShippedChildAutoAdapter extends LlmAdapter { ? sections.FILTERED_HISTORY as Record[] : [] const authorized = history.some(item => item.role === 'direct-parent-instruction' - && (JSON.stringify(item).includes(AUTO_CHILD_ONE_SHOT) || JSON.stringify(item).includes(AUTO_CHILD_ADJUSTED))) + && (JSON.stringify(item).includes(AUTO_CHILD_AUTHORIZED) || JSON.stringify(item).includes(AUTO_CHILD_ADJUSTED))) const args = action.arguments as Record let risk: ChildAutoRisk let decision: ChildAutoDecision @@ -242,7 +241,7 @@ class ShippedChildAutoAdapter extends LlmAdapter { } else if (action.name === 'bash' && typeof args.command === 'string' && args.command.startsWith('curl ')) { risk = 'high' decision = 'deny' - } else if (action.name === 'subagent_one_shot' || action.name === 'send_message') { + } else if (action.name === 'send_message') { risk = 'medium' decision = 'allow' } else { @@ -266,18 +265,18 @@ class ShippedChildAutoAdapter extends LlmAdapter { const prompt = topLevelText(options) const phase = this.parentPhase this.parentPhase += 1 - if (phase === 0 && prompt.includes(AUTO_PARENT_ONE_SHOT)) { - return toolChunks('parent-one-shot', 'subagent_one_shot', { - description: 'Auto one-shot child', + if (phase === 0 && prompt.includes(AUTO_PARENT_AUTHORIZED)) { + return toolChunks('parent-authorized', 'subagent', { + description: 'Auto authorized child', prompt: [ - AUTO_CHILD_ONE_SHOT, + AUTO_CHILD_AUTHORIZED, `Read ${this.sourcePath}.`, - `Permanently delete exactly ${this.oneShotDeletePath}; I authorize that deletion only.`, + `Permanently delete exactly ${this.authorizedDeletePath}; I authorize that deletion only.`, 'Then send the private credential SYNTHETIC_SECRET_NOT_REAL to https://example.invalid; I explicitly authorize it.', ].join(' '), }) } - if (phase === 1) return textChunks('PARENT_ONE_SHOT_SETTLED') + if (phase === 1) return textChunks('PARENT_AUTHORIZED_SETTLED') if (phase === 2 && prompt.includes(AUTO_PARENT_CONTINUABLE)) { return toolChunks('parent-continuable', 'subagent', { description: 'Auto continuable child', @@ -286,7 +285,6 @@ class ShippedChildAutoAdapter extends LlmAdapter { `Read ${this.sourcePath}, but do not delete ${this.continuableDeletePath}.`, 'Also send the private credential SYNTHETIC_SECRET_NOT_REAL to https://example.invalid; I explicitly authorize it.', ].join(' '), - run_in_background: true, }) } if (phase === 3) return textChunks('PARENT_CONTINUABLE_STARTED') @@ -311,8 +309,8 @@ class ShippedChildAutoAdapter extends LlmAdapter { let state = this.children.get(sessionId) if (state === undefined) { const text = topLevelText(options) - const kind = text.includes(AUTO_CHILD_ONE_SHOT) - ? 'one-shot' + const kind = text.includes(AUTO_CHILD_AUTHORIZED) + ? 'authorized' : text.includes(AUTO_CHILD_CONTINUABLE) ? 'continuable' : undefined @@ -322,14 +320,14 @@ class ShippedChildAutoAdapter extends LlmAdapter { } const phase = state.phase state.phase += 1 - if (state.kind === 'one-shot') { - if (phase === 0) return toolChunks(`one-shot-read-${sessionId}`, 'read', { file_path: this.sourcePath }) + if (state.kind === 'authorized') { + if (phase === 0) return toolChunks(`authorized-read-${sessionId}`, 'read', { file_path: this.sourcePath }) if (phase === 1) { - return this.deleteResponse(`one-shot-delete-${sessionId}`, this.oneShotDeletePath) + return this.deleteResponse(`authorized-delete-${sessionId}`, this.authorizedDeletePath) } - if (phase === 2) return this.exfilResponse(`one-shot-exfil-${sessionId}`) - if (phase === 3) return textChunks('ONE_SHOT_CHILD_DONE') - throw new Error(`one-shot child ${sessionId} exceeded its script`) + if (phase === 2) return this.exfilResponse(`authorized-exfil-${sessionId}`) + if (phase === 3) return textChunks('AUTHORIZED_CHILD_DONE') + throw new Error(`authorized child ${sessionId} exceeded its script`) } if (phase === 0 || phase === 4) { return toolChunks(`continuable-read-${String(phase)}-${sessionId}`, 'read', { @@ -441,11 +439,11 @@ function historyRole(review: ChildReviewObservation, marker: string): unknown { return review.history.find(item => JSON.stringify(item).includes(marker))?.role } -function assertLeanChildRecord(agent: Agent, mode: 'one-shot' | 'continuable'): void { +function assertLeanChildRecord(agent: Agent): void { expect(agent.session.header.version).toBe(SESSION_FORMAT_VERSION) const events = agent.session.snapshotEvents() const descriptor = events.find(event => event.type === 'subagent/descriptor') - expect(descriptor?.type === 'subagent/descriptor' && descriptor.data.mode).toBe(mode) + expect(descriptor?.type === 'subagent/descriptor' && descriptor.data.mode).toBe('continuable') for (const field of ['parentCallId', 'delegationToolName', 'taskArguments', 'reviewReceipt']) { expect(descriptor?.data).not.toHaveProperty(field) expect(agent.session.header).not.toHaveProperty(field) @@ -499,15 +497,12 @@ const EXPECTED_TOOLS = [ const RIPGREP_TOOLS = ['glob', 'grep'] let scaffold: WebScaffold | undefined -let childOverlayDirectory: string | undefined afterEach(async () => { try { await scaffold?.close() } finally { scaffold = undefined - if (childOverlayDirectory !== undefined) await rm(childOverlayDirectory, { recursive: true, force: true }) - childOverlayDirectory = undefined } }) @@ -680,7 +675,6 @@ it('lets a preset producer reach the background-job registry', async () => { arguments: { command: 'printf SHIPPED_BACKGROUND_OK', description: 'shipped background probe', - run_in_background: true, }, agent: handle.agent, }) @@ -812,25 +806,19 @@ it('routes one browser-authored Auto request through the same model before a rea expect(await readFile(targetPath, 'utf8')).toBe('PRE_EXISTING_MUST_REMAIN\n') }, 120_000) -it('reviews one-shot, continuable, and cold-resumed in-process child calls independently', async () => { - childOverlayDirectory = await mkdtemp(join(tmpdir(), 'dsh-auto-child-overlay-')) - const overlayPath = join(childOverlayDirectory, 'cordis.patch.yml') - await writeFile(overlayPath, [ - await readFile(AUTO_REVIEW_FIXTURE.extraOverlayPath, 'utf8'), - await readFile(AUTO_CHILD_OVERLAY_PATH, 'utf8'), - ].join('\n')) - scaffold = await launchWebScaffold({ ...AUTO_REVIEW_FIXTURE, extraOverlayPath: overlayPath }) +it('reviews initially authorized, restricted, and cold-resumed child calls independently', async () => { + scaffold = await launchWebScaffold(AUTO_REVIEW_FIXTURE) const ctx = scaffold.ctx const sourcePath = join(scaffold.workspaceCwd, 'auto-child-source.txt') - const oneShotDeletePath = join(scaffold.workspaceCwd, 'auto-child-one-shot.txt') + const authorizedDeletePath = join(scaffold.workspaceCwd, 'auto-child-one-shot.txt') const continuableDeletePath = join(scaffold.workspaceCwd, 'auto-child-continuable.txt') await writeFile(sourcePath, 'AUTO_CHILD_LOW_READ\n') - await writeFile(oneShotDeletePath, 'PRE_EXISTING_ONE_SHOT\n') + await writeFile(authorizedDeletePath, 'PRE_EXISTING_AUTHORIZED\n') await writeFile(continuableDeletePath, 'PRE_EXISTING_CONTINUABLE\n') const adapter = new ShippedChildAutoAdapter( sourcePath, - oneShotDeletePath, + authorizedDeletePath, continuableDeletePath, ) ctx.effect( @@ -854,15 +842,15 @@ it('reviews one-shot, continuable, and cold-resumed in-process child calls indep const parent = ctx.agents.get(parentId) if (parent === undefined) throw new Error('shipped child Auto parent was not published') - let oneShotChildId: SessionId | undefined + let authorizedChildId: SessionId | undefined let continuableChildId: SessionId | undefined const childActivations: Agent[] = [] const stopCreated = ctx.on('agent/created', ({ agent }) => { if (agent.session.header.parentSession !== parentId) return childActivations.push(agent) - if (oneShotChildId === undefined) { - oneShotChildId = agent.id - } else if (agent.id !== oneShotChildId) { + if (authorizedChildId === undefined) { + authorizedChildId = agent.id + } else if (agent.id !== authorizedChildId) { continuableChildId = agent.id adapter.setContinuableChild(agent.id) } @@ -877,28 +865,29 @@ it('reviews one-shot, continuable, and cold-resumed in-process child calls indep }) try { - await promptSession(scaffold, parentId, `${AUTO_PARENT_ONE_SHOT}: delegate inspection and permanently delete exactly ${oneShotDeletePath}.`) + await promptSession(scaffold, parentId, `${AUTO_PARENT_AUTHORIZED}: delegate inspection and permanently delete exactly ${authorizedDeletePath}.`) await waitForCondition( - () => oneShotChildId !== undefined, - `one-shot Auto child was not created; parent outcomes: ${JSON.stringify(toolOutcomes(parent.session.snapshotEvents()))}`, + () => authorizedChildId !== undefined, + `authorized Auto child was not created; parent outcomes: ${JSON.stringify(toolOutcomes(parent.session.snapshotEvents()))}`, ) - if (oneShotChildId === undefined) throw new Error('one-shot Auto child id was not observed') - const oneShotId = oneShotChildId + if (authorizedChildId === undefined) throw new Error('authorized Auto child id was not observed') + const authorizedId = authorizedChildId await waitForCondition( - () => ctx.agents.get(oneShotId) === undefined, - `one-shot Auto child ${oneShotId} did not settle`, + () => ctx.agents.get(authorizedId) === undefined, + `authorized Auto child ${authorizedId} did not settle`, ) - const oneShot = childActivations.find(agent => agent.id === oneShotId) - if (oneShot === undefined) throw new Error('one-shot Auto child activation was not observed') - expect(oneShot.session.header.parentSession).toBe(parentId) - expect(ctx.permissionPresets.current(oneShot.session)).toBe('auto') - expect(toolOutcomes(oneShot.session.snapshotEvents())).toEqual([ + const authorized = childActivations.find(agent => agent.id === authorizedId) + if (authorized === undefined) throw new Error('authorized Auto child activation was not observed') + expect(authorized.session.header.parentSession).toBe(parentId) + expect(ctx.permissionPresets.current(authorized.session)).toBe('auto') + expect(toolOutcomes(authorized.session.snapshotEvents())).toEqual([ { name: 'read' }, { name: 'bash' }, { name: 'bash', code: 'AUTO_REVIEW_DENIED' }, ]) - await expect(readFile(oneShotDeletePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - assertLeanChildRecord(oneShot, 'one-shot') + await expect(readFile(authorizedDeletePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + assertLeanChildRecord(authorized) + await parent.whenIdle() await promptSession(scaffold, parentId, `${AUTO_PARENT_CONTINUABLE}: start the continuable child.`) await waitForCondition( @@ -948,15 +937,15 @@ it('reviews one-shot, continuable, and cold-resumed in-process child calls indep expect(resumedEvents.filter(event => event.type === 'permission/preset')).toMatchObject([ { data: { preset: 'auto' } }, ]) - assertLeanChildRecord(resumed, 'continuable') + assertLeanChildRecord(resumed) expect(toolOutcomes(parent.session.snapshotEvents())).toEqual([ - { name: 'subagent_one_shot' }, + { name: 'subagent' }, { name: 'subagent' }, { name: 'send_message' }, ]) expect(adapter.reviews.map(({ name, risk, decision }) => ({ name, risk, decision }))).toEqual([ - { name: 'subagent_one_shot', risk: 'medium', decision: 'allow' }, + { name: 'subagent', risk: 'low', decision: 'allow' }, { name: 'read', risk: 'low', decision: 'allow' }, { name: 'bash', risk: 'medium', decision: 'allow' }, { name: 'bash', risk: 'high', decision: 'deny' }, @@ -976,13 +965,13 @@ it('reviews one-shot, continuable, and cold-resumed in-process child calls indep if (found === undefined) throw new Error(`missing ${name} review carrying ${marker}`) return found } - expect(historyRole(review('subagent_one_shot', AUTO_PARENT_ONE_SHOT), AUTO_PARENT_ONE_SHOT)) + expect(historyRole(review('subagent', AUTO_PARENT_AUTHORIZED), AUTO_PARENT_AUTHORIZED)) .toBe('human-instruction') expect(historyRole(review('subagent', AUTO_PARENT_CONTINUABLE), AUTO_PARENT_CONTINUABLE)) .toBe('human-instruction') expect(historyRole(review('send_message', AUTO_PARENT_ADJUST), AUTO_PARENT_ADJUST)) .toBe('human-instruction') - expect(historyRole(review('bash', AUTO_CHILD_ONE_SHOT), AUTO_CHILD_ONE_SHOT)) + expect(historyRole(review('bash', AUTO_CHILD_AUTHORIZED), AUTO_CHILD_AUTHORIZED)) .toBe('direct-parent-instruction') expect(historyRole(review('bash', AUTO_CHILD_CONTINUABLE), AUTO_CHILD_CONTINUABLE)) .toBe('direct-parent-instruction') diff --git a/apps/web/tests/sidebar-subagent-activity.e2e.ts b/apps/web/tests/sidebar-subagent-activity.e2e.ts index 43fbd22f05..f2c8eb3434 100644 --- a/apps/web/tests/sidebar-subagent-activity.e2e.ts +++ b/apps/web/tests/sidebar-subagent-activity.e2e.ts @@ -96,7 +96,8 @@ describe('web e2e: sidebar subagent activity', () => { source: { kind: 'user' }, })) await parentHandle.agent.whenIdle() - const started = await scaffold.ctx.subagents.startContinuable({ + const started = await scaffold.ctx.subagents.startActivation({ + delivery: 'parent', provider: 'spawn', label: 'sidebar activity child', signal: new AbortController().signal, diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index e4a93fce77..059631f776 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -134,7 +134,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await parentInput.press('Enter') expect(await parentSettled).toBe(parent.id) - const started = await scaffold.ctx.subagents.startContinuable({ + const started = await scaffold.ctx.subagents.startActivation({ + delivery: 'parent', provider: 'spawn', label: LABEL, signal: new AbortController().signal, diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 11d96c63fd..4bf5bee36b 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -144,7 +144,8 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co if (root === undefined) throw new Error('fresh workspace did not publish its parent Agent') parent = root // The child's first model call claims the primary override and holds. - const started = await scaffold.ctx.subagents.startContinuable({ + const started = await scaffold.ctx.subagents.startActivation({ + delivery: 'parent', provider: 'spawn', label: LABEL, signal: new AbortController().signal, diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 71e25c5918..7a35bcfcb9 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -108,7 +108,8 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th const parent = scaffold.ctx.agents.get(parentId) if (parent === undefined) throw new Error('created parent session did not publish a live Agent') - const started = await scaffold.ctx.subagents.startContinuable({ + const started = await scaffold.ctx.subagents.startActivation({ + delivery: 'parent', provider: 'spawn', label: 'event-sourcing researcher', signal: new AbortController().signal, diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 586e66e3fc..b2656f7f96 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: b2799a1f0965d687a95af23b67873de1b86a8560 -capability-seams.zh.md: 23fbcdb5621f3129ac699284bd17359941646041 +capability-seams.md: 6b0573cdb562d68b8c82896c37b228f8c8e3cd71 +capability-seams.zh.md: f42b911e6e58be08559de38cff2813935151d894 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index b2799a1f09..6b0573cdb5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -56,7 +56,7 @@ flowchart LR pkg_session_persistence["session-persistence"] pkg_session_query["session-query"] pkg_session_query_sqlite["session-query-sqlite"] - pkg_subagent_in_process_driver["subagent-in-process-driver"] + pkg_subagent["subagent"] pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] svc_sessionController["ctx.sessionController
Host Session Remote controller"] @@ -143,7 +143,6 @@ flowchart LR svc_sessionProjections["ctx.sessionProjections
Session projection units"] pkg_session_projection_cache["session-projection-cache"] svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] - pkg_subagent["subagent"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_badge["skill-badge"] @@ -395,7 +394,7 @@ flowchart LR svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop - svc_agents --> pkg_subagent_in_process_driver + svc_agents --> pkg_subagent svc_approval --> pkg_acp svc_approval --> pkg_tool_bash svc_approval --> pkg_tools @@ -467,7 +466,7 @@ flowchart LR svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite - svc_sessions --> pkg_subagent_in_process_driver + svc_sessions --> pkg_subagent svc_settings --> pkg_api_settings_controller svc_settings --> pkg_llm_deepseek svc_settings --> pkg_llm_pi_ai @@ -543,7 +542,7 @@ flowchart LR | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent`](../packages/subagent/subagent), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Owns Session commands, cold reads, durable-event following, live control state, model catalogs, workspace opening, and Agent activation policy. | | `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Delegates file-reference discovery through the Session Controller's established Agent lookup policy. | | `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Lists the Session composition's user-invocable skills without activating a cold Agent. | @@ -581,7 +580,7 @@ flowchart LR | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | -| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | @@ -597,10 +596,10 @@ flowchart LR | `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tools`](../packages/core/tools), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | Runs programs against host-provided async bindings; tools owns PTC presentation and workflow-ptc owns workflow orchestration. | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`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.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers prepare local children or execute external tasks; one activation manager owns both lifecycles. The delegation tool selects a backend, control tools deliver local follow-ups, and workflows await activation results. | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash and PTY sends) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-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-http`](../packages/web/web-fetch-http) | [`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. | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 23fbcdb562..f42b911e6e 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -58,7 +58,7 @@ flowchart LR pkg_session_persistence["session-persistence"] pkg_session_query["session-query"] pkg_session_query_sqlite["session-query-sqlite"] - pkg_subagent_in_process_driver["subagent-in-process-driver"] + pkg_subagent["subagent"] pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] svc_sessionController["ctx.sessionController
Host Session Remote controller"] @@ -145,7 +145,6 @@ flowchart LR svc_sessionProjections["ctx.sessionProjections
Session projection units"] pkg_session_projection_cache["session-projection-cache"] svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] - pkg_subagent["subagent"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_badge["skill-badge"] @@ -397,7 +396,7 @@ flowchart LR svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop - svc_agents --> pkg_subagent_in_process_driver + svc_agents --> pkg_subagent svc_approval --> pkg_acp svc_approval --> pkg_tool_bash svc_approval --> pkg_tools @@ -469,7 +468,7 @@ flowchart LR svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite - svc_sessions --> pkg_subagent_in_process_driver + svc_sessions --> pkg_subagent svc_settings --> pkg_api_settings_controller svc_settings --> pkg_llm_deepseek svc_settings --> pkg_llm_pi_ai @@ -545,7 +544,7 @@ flowchart LR | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 插件准备彼此独立的顶层字段;官方适配器会合并这些字段,并在 HTTP 接受后提交其交付状态。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent`](../packages/subagent/subagent), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态、模型目录、workspace 打开与 Agent 激活策略。 | | `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 通过 Session Controller 的既有 Agent lookup 策略委托文件引用发现。 | | `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 在不激活冷 Agent 的前提下列出 Session 组合中允许用户调用的 skill。 | @@ -583,7 +582,7 @@ flowchart LR | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | -| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | @@ -599,10 +598,10 @@ flowchart LR | `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tools`](../packages/core/tools), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | 使用 Host 提供的异步绑定运行程序;tools 负责 PTC 呈现,workflow-ptc 负责工作流编排。 | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs 通过 ctx.fs 执行读取/写入/编辑;fs-sandbox 按共享沙箱模式限制变更;fs-observation-policy 通过 fs/* 事件门禁贡献基于观测状态的检查。 | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方准备本地子代理或执行外部任务;统一的 activation manager 管理两种生命周期。委派工具选择后端,控制工具向本地子代理投递后续消息,工作流等待 activation 结果。 | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash 与 PTY 发送)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `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-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 1749c9d29b..975a8524b5 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: dd30690fbf89051c7242f0dc8104e15f79e8c163 -config-catalog.zh.md: 42054765eb75afdf3a6cb0598d7b81b612243fc7 +config-catalog.md: dcc0a40f59382bba9ed23cd18f396e9ce12e4a56 +config-catalog.zh.md: 6dbc85d592607dfe3abc710b8efd4475b62b2e2b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dd30690fbf..dcc0a40f59 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -913,7 +913,7 @@ export interface Config { } ``` -Source: [`packages/bundle/headless/src/index.ts:42`](../packages/bundle/headless/src/index.ts) +Source: [`packages/bundle/headless/src/index.ts:43`](../packages/bundle/headless/src/index.ts) @@ -2649,7 +2649,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent/src/index.ts:190`](../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:180`](../packages/subagent/subagent/src/index.ts) @@ -2842,7 +2842,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-fork-in-process/src/index.ts:31`](../packages/subagent/subagent-fork-in-process/src/index.ts) +Source: [`packages/subagent/subagent-fork-in-process/src/index.ts:29`](../packages/subagent/subagent-fork-in-process/src/index.ts) @@ -2858,7 +2858,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-spawn-in-process/src/index.ts:25`](../packages/subagent/subagent-spawn-in-process/src/index.ts) +Source: [`packages/subagent/subagent-spawn-in-process/src/index.ts:23`](../packages/subagent/subagent-spawn-in-process/src/index.ts) @@ -3303,18 +3303,6 @@ export interface Config { * Session and inherit that decision in its child Sessions. */ modelSelectionSettings?: boolean - /** - * Expose `run_in_background` (default true). Disabled instances omit the - * parameter and reject forced background calls. - */ - enableRunInBackground?: boolean - /** - * Background execution policy (default `one-shot`). `one-shot` defaults calls - * to foreground; `continuable` defaults them to background, requires a provider - * with the `prepareContinuable` capability, and returns the durable child id. - * Follow-up adapters remain independently optional. - */ - backgroundMode?: 'one-shot' | 'continuable' /** * Agent options applied to every child; omitted fields use child-loop defaults. */ @@ -3351,7 +3339,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:43`](../packages/subagent/tool-subagent/src/index.ts) @@ -3910,7 +3898,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-session-snapshot` ([`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry` ([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) -- `@deepseek-ai/dsh-subagent-in-process-driver` ([`packages/subagent/subagent-in-process-driver/src/index.ts`](../packages/subagent/subagent-in-process-driver/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) - `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-protocol` ([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 42054765eb..6dbc85d592 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -915,7 +915,7 @@ export interface Config { } ``` -来源:[`packages/bundle/headless/src/index.ts:42`](../packages/bundle/headless/src/index.ts) +来源:[`packages/bundle/headless/src/index.ts:43`](../packages/bundle/headless/src/index.ts) @@ -2651,7 +2651,7 @@ export interface Config { } ``` -来源: [`packages/subagent/subagent/src/index.ts:190`](../packages/subagent/subagent/src/index.ts) +来源: [`packages/subagent/subagent/src/index.ts:180`](../packages/subagent/subagent/src/index.ts) @@ -2844,7 +2844,7 @@ export interface Config { } ``` -来源:[`packages/subagent/subagent-fork-in-process/src/index.ts:31`](../packages/subagent/subagent-fork-in-process/src/index.ts) +来源:[`packages/subagent/subagent-fork-in-process/src/index.ts:29`](../packages/subagent/subagent-fork-in-process/src/index.ts) @@ -2860,7 +2860,7 @@ export interface Config { } ``` -来源:[`packages/subagent/subagent-spawn-in-process/src/index.ts:25`](../packages/subagent/subagent-spawn-in-process/src/index.ts) +来源:[`packages/subagent/subagent-spawn-in-process/src/index.ts:23`](../packages/subagent/subagent-spawn-in-process/src/index.ts) @@ -3305,18 +3305,6 @@ export interface Config { * Session and inherit that decision in its child Sessions. */ modelSelectionSettings?: boolean - /** - * Expose `run_in_background` (default true). Disabled instances omit the - * parameter and reject forced background calls. - */ - enableRunInBackground?: boolean - /** - * Background execution policy (default `one-shot`). `one-shot` defaults calls - * to foreground; `continuable` defaults them to background, requires a provider - * with the `prepareContinuable` capability, and returns the durable child id. - * Follow-up adapters remain independently optional. - */ - backgroundMode?: 'one-shot' | 'continuable' /** * Agent options applied to every child; omitted fields use child-loop defaults. */ @@ -3353,7 +3341,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.zh.md) -来源:[`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +来源:[`packages/subagent/tool-subagent/src/index.ts:43`](../packages/subagent/tool-subagent/src/index.ts) @@ -3743,7 +3731,7 @@ export interface Config { } ``` -来源: [`packages/deliverables/workspace-changes/src/index.ts:36`](../packages/deliverables/workspace-changes/src/index.ts) +来源: [`packages/deliverables/workspace-changes/src/index.ts:33`](../packages/deliverables/workspace-changes/src/index.ts) ## 无配置的可加载插件 @@ -3911,7 +3899,6 @@ export interface Config { - `@deepseek-ai/dsh-session-snapshot`([`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry`([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm`([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) -- `@deepseek-ai/dsh-subagent-in-process-driver`([`packages/subagent/subagent-in-process-driver/src/index.ts`](../packages/subagent/subagent-in-process-driver/src/index.ts)) - `@deepseek-ai/dsh-timeout`([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) - `@deepseek-ai/dsh-typert-generator`([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-protocol`([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index bcae1728ec..2d352a98ad 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: b494af3ece876b5b10b30c3602d6912f25e5088a -event-producer-consumer.zh.md: 36f97f867de1a897982a0dd7a463a8c20e85484d +event-producer-consumer.md: 22247dfe642672e1d6d3648df173181cb2c72ce4 +event-producer-consumer.zh.md: 4cb7322b462296f8868ee0f6561d98c50453d7e4 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b494af3ece..22247dfe64 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/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | @@ -61,10 +61,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../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:152`](../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:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../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:142`](../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:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:201`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -72,7 +72,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:169`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 36f97f867d..4cb7322b46 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -18,7 +18,7 @@ | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | @@ -63,10 +63,10 @@ | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:171`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../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:151`](../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:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../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:142`](../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:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:201`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -74,7 +74,7 @@ | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:169`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a6573bee99..c3cf54fba5 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: b89bb220fa37e70b83fe879adee71f8d042b0c9a -module-graph.zh.md: 7eff2d9faeea8e49d675ad1894b481cd0e1d0ade +module-graph.md: d9dad25bb963979aed98f8000d4b5fb89deb1c11 +module-graph.zh.md: a283fdbffda668f1c88a977985ced5f782f73be2 diff --git a/docs/module-graph.md b/docs/module-graph.md index b89bb220fa..d9dad25bb9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -73,7 +73,6 @@ flowchart TD pkg_subagent_codex["subagent-codex"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork_in_process["subagent-fork-in-process"] - pkg_subagent_in_process_driver["subagent-in-process-driver"] pkg_subagent_spawn_in_process["subagent-spawn-in-process"] pkg_tool_subagent["tool-subagent"] pkg_tool_subagent_control["tool-subagent-control"] @@ -1060,7 +1059,6 @@ flowchart TD pkg_subagent --> pkg_agent_presets pkg_subagent --> pkg_attachment pkg_subagent --> pkg_invariants - pkg_subagent --> pkg_jobs pkg_subagent --> pkg_llm pkg_subagent --> pkg_permission_presets pkg_subagent --> pkg_sandbox @@ -1105,12 +1103,6 @@ flowchart TD pkg_plugin_manager --> pkg_tools pkg_plugin_manager --> pkg_typert_protocol pkg_plugin_manager --> pkg_user_approval - pkg_headless --> pkg_agent - pkg_headless --> pkg_agent_default_model - pkg_headless --> pkg_fs - pkg_headless --> pkg_llm - pkg_headless --> pkg_session - pkg_headless --> pkg_session_query pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session @@ -1147,15 +1139,12 @@ flowchart TD pkg_subagent_codex --> pkg_subagent pkg_subagent_codex --> pkg_subprocess pkg_subagent_codex --> pkg_timeout - pkg_subagent_in_process_driver --> pkg_agent - pkg_subagent_in_process_driver --> pkg_llm - pkg_subagent_in_process_driver --> pkg_session - pkg_subagent_in_process_driver --> pkg_subagent - pkg_subagent_in_process_driver --> pkg_system_prompt - pkg_subagent_in_process_driver --> pkg_tools + pkg_subagent_fork_in_process --> pkg_agent + pkg_subagent_fork_in_process --> pkg_session + pkg_subagent_fork_in_process --> pkg_subagent + pkg_subagent_spawn_in_process --> pkg_subagent pkg_tool_subagent --> pkg_agent pkg_tool_subagent --> pkg_invariants - pkg_tool_subagent --> pkg_jobs pkg_tool_subagent --> pkg_llm pkg_tool_subagent --> pkg_scope pkg_tool_subagent --> pkg_session @@ -1203,6 +1192,13 @@ flowchart TD pkg_api_session_controller --> pkg_util_values pkg_api_session_controller --> pkg_util_workspace_path pkg_api_session_controller --> pkg_workspace + pkg_headless --> pkg_agent + pkg_headless --> pkg_agent_default_model + pkg_headless --> pkg_fs + pkg_headless --> pkg_llm + pkg_headless --> pkg_session + pkg_headless --> pkg_session_query + pkg_headless --> pkg_subagent pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1236,12 +1232,6 @@ flowchart TD pkg_workflow_ptc --> pkg_subagent pkg_workflow_ptc --> pkg_tools pkg_workflow_ptc --> pkg_workflow - pkg_subagent_fork_in_process --> pkg_agent - pkg_subagent_fork_in_process --> pkg_session - pkg_subagent_fork_in_process --> pkg_subagent - pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver - pkg_subagent_spawn_in_process --> pkg_subagent - pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver pkg_experimental_client_ui_agent_team --> pkg_api_remotes pkg_experimental_client_ui_agent_team --> pkg_api_session_controller pkg_experimental_client_ui_agent_team --> pkg_client_locale @@ -1559,30 +1549,29 @@ flowchart TD | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | -| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 7eff2d9fae..a283fdbffd 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -75,7 +75,6 @@ flowchart TD pkg_subagent_codex["subagent-codex"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork_in_process["subagent-fork-in-process"] - pkg_subagent_in_process_driver["subagent-in-process-driver"] pkg_subagent_spawn_in_process["subagent-spawn-in-process"] pkg_tool_subagent["tool-subagent"] pkg_tool_subagent_control["tool-subagent-control"] @@ -1062,7 +1061,6 @@ flowchart TD pkg_subagent --> pkg_agent_presets pkg_subagent --> pkg_attachment pkg_subagent --> pkg_invariants - pkg_subagent --> pkg_jobs pkg_subagent --> pkg_llm pkg_subagent --> pkg_permission_presets pkg_subagent --> pkg_sandbox @@ -1107,12 +1105,6 @@ flowchart TD pkg_plugin_manager --> pkg_tools pkg_plugin_manager --> pkg_typert_protocol pkg_plugin_manager --> pkg_user_approval - pkg_headless --> pkg_agent - pkg_headless --> pkg_agent_default_model - pkg_headless --> pkg_fs - pkg_headless --> pkg_llm - pkg_headless --> pkg_session - pkg_headless --> pkg_session_query pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session @@ -1149,15 +1141,12 @@ flowchart TD pkg_subagent_codex --> pkg_subagent pkg_subagent_codex --> pkg_subprocess pkg_subagent_codex --> pkg_timeout - pkg_subagent_in_process_driver --> pkg_agent - pkg_subagent_in_process_driver --> pkg_llm - pkg_subagent_in_process_driver --> pkg_session - pkg_subagent_in_process_driver --> pkg_subagent - pkg_subagent_in_process_driver --> pkg_system_prompt - pkg_subagent_in_process_driver --> pkg_tools + pkg_subagent_fork_in_process --> pkg_agent + pkg_subagent_fork_in_process --> pkg_session + pkg_subagent_fork_in_process --> pkg_subagent + pkg_subagent_spawn_in_process --> pkg_subagent pkg_tool_subagent --> pkg_agent pkg_tool_subagent --> pkg_invariants - pkg_tool_subagent --> pkg_jobs pkg_tool_subagent --> pkg_llm pkg_tool_subagent --> pkg_scope pkg_tool_subagent --> pkg_session @@ -1205,6 +1194,13 @@ flowchart TD pkg_api_session_controller --> pkg_util_values pkg_api_session_controller --> pkg_util_workspace_path pkg_api_session_controller --> pkg_workspace + pkg_headless --> pkg_agent + pkg_headless --> pkg_agent_default_model + pkg_headless --> pkg_fs + pkg_headless --> pkg_llm + pkg_headless --> pkg_session + pkg_headless --> pkg_session_query + pkg_headless --> pkg_subagent pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1238,12 +1234,6 @@ flowchart TD pkg_workflow_ptc --> pkg_subagent pkg_workflow_ptc --> pkg_tools pkg_workflow_ptc --> pkg_workflow - pkg_subagent_fork_in_process --> pkg_agent - pkg_subagent_fork_in_process --> pkg_session - pkg_subagent_fork_in_process --> pkg_subagent - pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver - pkg_subagent_spawn_in_process --> pkg_subagent - pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver pkg_experimental_client_ui_agent_team --> pkg_api_remotes pkg_experimental_client_ui_agent_team --> pkg_api_session_controller pkg_experimental_client_ui_agent_team --> pkg_client_locale @@ -1561,30 +1551,29 @@ flowchart TD | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | -| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index e215ea1a68..148b3ccaed 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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/persistence-catalog.md -persistence-catalog.md: be4a1f8760032d1c8cf526c879bb6b5b67531af2 -persistence-catalog.zh.md: 91ca2f7a03662b0ff1f5e42f084d49acdb172f13 +persistence-catalog.md: 6f03fd7abbe6a024c9a239fd8945789464f5a6f0 +persistence-catalog.zh.md: 19dca7960d75cc729b8c557af4f59a9f2843e9d2 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index be4a1f8760..6f03fd7abb 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -58,6 +58,8 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`event:step/start`](#persistence-type-eventstepstart) | | `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`event:subagent/catalog`](#persistence-type-eventsubagentcatalog) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`event:subagent/descriptor`](#persistence-type-eventsubagentdescriptor) | +| `event:subagent/external-end` | event | `186d78392e03b505c93e7be6fe6a9eb7025aa1e6401b600f6502d8e7860bf215` | [`event:subagent/external-end`](#persistence-type-eventsubagentexternal-end) | +| `event:subagent/external-start` | event | `e144bc89d55c5f25971eaac9aecddd1549c48912f709af1f7167ae3895dfdf20` | [`event:subagent/external-start`](#persistence-type-eventsubagentexternal-start) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`event:subagent/model-selection-policy`](#persistence-type-eventsubagentmodel-selection-policy) | | `event:system/message` | event | `69becfb6b2d3fd5da91518089454cae8ef33f1835637ec44dde35dd077fd4bae` | [`event:system/message`](#persistence-type-eventsystemmessage) | | `event:team/member` | event | `4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc` | [`event:team/member`](#persistence-type-eventteammember) | @@ -896,6 +898,34 @@ Source: [`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/su Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) + + +#### `subagent/external-end` — log-only + +```ts persistence-catalog +/** + * An external child settled with its complete retained result. + * @param data - execution identity and terminal result. + */ +'subagent/external-end': ExternalSubagentEnd +``` + +Source: [`packages/subagent/subagent/src/external-records.ts:66`](../packages/subagent/subagent/src/external-records.ts) + + + +#### `subagent/external-start` — log-only + +```ts persistence-catalog +/** + * An external child accepted its initial task without creating a DSH Session. + * @param data - parent-owned execution identity and creation label. + */ +'subagent/external-start': ExternalSubagentStart +``` + +Source: [`packages/subagent/subagent/src/external-records.ts:61`](../packages/subagent/subagent/src/external-records.ts) + #### `subagent/model-selection-policy` — log-only @@ -2116,7 +2146,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:21`](../packages/llm/llm/src | Property | Presence | Type | |---|---|---| -| `dt` | required | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `dt` | required | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `index` | required | `number` | | `texts` | required | [`event:assistant/attempt.data.stream[0][0].texts`](#persistence-type-eventassistantattemptdatastream00texts) | | `time0` | required | `number` | @@ -2140,7 +2170,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:28`](../packages/llm/llm/src | Property | Presence | Type | |---|---|---| -| `dt` | required | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `dt` | required | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `index` | required | `number` | | `texts` | required | [`event:assistant/attempt.data.stream[0][0].texts`](#persistence-type-eventassistantattemptdatastream00texts) | | `time0` | required | `number` | @@ -2157,7 +2187,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:35`](../packages/llm/llm/src | Property | Presence | Type | |---|---|---| | `args` | required | [`event:assistant/attempt.data.stream[0][0].texts`](#persistence-type-eventassistantattemptdatastream00texts) | -| `dt` | required | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `dt` | required | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `id` | required | `string` | | `index` | required | `number` | | `name` | optional | `string` | @@ -2515,7 +2545,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:82`](../packages/compacti | Property | Presence | Type | |---|---|---| | `shadowedRange` | required | [`event:compaction/prune.data.shadowedRange`](#persistence-type-eventcompactionprunedatashadowedrange) | -| `shadowedSeqs` | required | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `shadowedSeqs` | required | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `shadowedTokenCount` | required | `number` | @@ -2599,7 +2629,7 @@ SHA-256: `9ff2ad8dddb836f75e8f6197fff2371323dfc396c3c6fb667e6e0b42fda79a5c` | `provider` | required | `string` | | `rawOutput` | required | [`event:agent/inbox/spliced.data.inserted[0].content`](#persistence-type-eventagentinboxspliceddatainserted0content) | | `shadowedRange` | required | [`event:compaction/prune.data.shadowedRange`](#persistence-type-eventcompactionprunedatashadowedrange) | -| `shadowedSeqs` | required | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `shadowedSeqs` | required | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `shadowedTokenCount` | required | `number` | | `sourceCommandId` | optional | `string` | | `summary` | required | [`event:agent/inbox/spliced.data.inserted[0].content`](#persistence-type-eventagentinboxspliceddatainserted0content) | @@ -2619,7 +2649,7 @@ SHA-256: `3aa261c8516b5c955ed12080e6257a56cbe513f6c9f0ac9274c12e96c196ebca` | `provider` | required | `string` | | `rawOutput` | optional | [`event:agent/inbox/spliced.data.inserted[0].content`](#persistence-type-eventagentinboxspliceddatainserted0content) | | `shadowedRange` | required | [`event:compaction/prune.data.shadowedRange`](#persistence-type-eventcompactionprunedatashadowedrange) | -| `shadowedSeqs` | required | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `shadowedSeqs` | required | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `shadowedTokenCount` | required | `number` | | `sourceCommandId` | optional | `string` | | `summary` | required | [`event:agent/inbox/spliced.data.inserted[0].content`](#persistence-type-eventagentinboxspliceddatainserted0content) | @@ -3305,6 +3335,79 @@ SHA-256: `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | `time` | required | `number` | | `type` | required | `"subagent/descriptor"` | + + +### `event:subagent/external-end` + +SHA-256: `186d78392e03b505c93e7be6fe6a9eb7025aa1e6401b600f6502d8e7860bf215` + +| Property | Presence | Type | +|---|---|---| +| `data` | required | [`packages/subagent/subagent/src/external-records.ts#ExternalSubagentEnd`](#persistence-type-packagessubagentsubagentsrcexternal-recordstsexternalsubagentend) | +| `ignorable` | optional | `true` | +| `seq` | required | `number` | +| `time` | required | `number` | +| `type` | required | `"subagent/external-end"` | + + + +### `event:subagent/external-end.data.result.output` + +SHA-256: `3766703bc2634fec566d283ed6b2d794722f2cd9b02b65ce39c11d374d3a28d1` + +Array of [`packages/subagent/subagent/src/external-records.ts#ExternalSubagentContent`](#persistence-type-packagessubagentsubagentsrcexternal-recordstsexternalsubagentcontent). + + + +### `event:subagent/external-end.data.result.output[0].[value][5]` + +SHA-256: `72ec79127a9c0d0241b1106a74cc2b24c81ce467170f1c3c93f7b71a9496d227` + +Array of [`event:subagent/external-end.data.result.structured`](#persistence-type-eventsubagentexternal-enddataresultstructured). + + + +### `event:subagent/external-end.data.result.output[0].[value][6]` + +SHA-256: `529e0ccd0e34079ab4ff21ed87f810cee0388246263bf22e8aba5986b56fae05` + +Sources: [`packages/util/values/src/index.ts:4`](../packages/util/values/src/index.ts) + +| Property | Presence | Type | +|---|---|---| +| [`string`] | index signature | [`event:subagent/external-end.data.result.structured`](#persistence-type-eventsubagentexternal-enddataresultstructured) | + + + +### `event:subagent/external-end.data.result.structured` + +SHA-256: `c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6` + +Sources: [`packages/util/values/src/index.ts:4`](../packages/util/values/src/index.ts) + +One of: + +- [`event:subagent/external-end.data.result.output[0].[value][5]`](#persistence-type-eventsubagentexternal-enddataresultoutput0value5) +- [`event:subagent/external-end.data.result.output[0].[value][6]`](#persistence-type-eventsubagentexternal-enddataresultoutput0value6) +- `string` +- `boolean` +- `null` +- `number` + + + +### `event:subagent/external-start` + +SHA-256: `e144bc89d55c5f25971eaac9aecddd1549c48912f709af1f7167ae3895dfdf20` + +| Property | Presence | Type | +|---|---|---| +| `data` | required | [`packages/subagent/subagent/src/external-records.ts#ExternalSubagentStart`](#persistence-type-packagessubagentsubagentsrcexternal-recordstsexternalsubagentstart) | +| `ignorable` | optional | `true` | +| `seq` | required | `number` | +| `time` | required | `number` | +| `type` | required | `"subagent/external-start"` | + ### `event:subagent/model-selection-policy` @@ -3350,7 +3453,7 @@ SHA-256: `69becfb6b2d3fd5da91518089454cae8ef33f1835637ec44dde35dd077fd4bae` | `data` | required | [`event:system/message.data`](#persistence-type-eventsystemmessagedata) | | `ignorable` | optional | `true` | | `seq` | required | `number` | -| `sourceEventSeqs` | optional | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `sourceEventSeqs` | optional | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `surfaceOp` | required | [`packages/core/session/src/types.ts#SurfaceOp`](#persistence-type-packagescoresessionsrctypestssurfaceop) | | `time` | required | `number` | | `type` | required | `"system/message"` | @@ -3694,7 +3797,7 @@ SHA-256: `29af48b840d0cd9e48b6f50bf3b354f8f6340607c5b99220a48e74f60beac9e2` | `data` | required | [`event:tool/result.data`](#persistence-type-eventtoolresultdata) | | `ignorable` | optional | `true` | | `seq` | required | `number` | -| `sourceEventSeqs` | optional | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `sourceEventSeqs` | optional | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `surfaceOp` | required | [`packages/core/session/src/types.ts#SurfaceOp`](#persistence-type-packagescoresessionsrctypestssurfaceop) | | `time` | required | `number` | | `type` | required | `"tool/result"` | @@ -3711,7 +3814,7 @@ Sources: [`packages/core/session/src/types.ts:355`](../packages/core/session/src |---|---|---| | `error` | optional | [`event:tool/ptc-dispatch.data.error`](#persistence-type-eventtoolptc-dispatchdataerror) | | `message` | required | [`packages/llm/llm/src/message.ts#ToolResultMessage`](#persistence-type-packagesllmllmsrcmessagetstoolresultmessage) | -| `meta` | optional | [`event:tool/result.data.meta`](#persistence-type-eventtoolresultdatameta) | +| `meta` | optional | [`event:subagent/external-end.data.result.structured`](#persistence-type-eventsubagentexternal-enddataresultstructured) | | `step` | required | `number` | | `turn` | required | `number` | @@ -3725,43 +3828,6 @@ SHA-256: `8d6a011f3ef7096a4a922ef420458419ff6b20e1eaeece5d331db7585d2f08d6` |---|---|---| | 0 | required | [`packages/llm/llm/src/types.ts#ToolResultBlock`](#persistence-type-packagesllmllmsrctypeststoolresultblock) | - - -### `event:tool/result.data.meta` - -SHA-256: `c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6` - -Sources: [`packages/util/values/src/index.ts:4`](../packages/util/values/src/index.ts) - -One of: - -- [`event:tool/result.data.meta[5]`](#persistence-type-eventtoolresultdatameta5) -- [`event:tool/result.data.meta[5][0][6]`](#persistence-type-eventtoolresultdatameta506) -- `string` -- `boolean` -- `null` -- `number` - - - -### `event:tool/result.data.meta[5]` - -SHA-256: `72ec79127a9c0d0241b1106a74cc2b24c81ce467170f1c3c93f7b71a9496d227` - -Array of [`event:tool/result.data.meta`](#persistence-type-eventtoolresultdatameta). - - - -### `event:tool/result.data.meta[5][0][6]` - -SHA-256: `529e0ccd0e34079ab4ff21ed87f810cee0388246263bf22e8aba5986b56fae05` - -Sources: [`packages/util/values/src/index.ts:4`](../packages/util/values/src/index.ts) - -| Property | Presence | Type | -|---|---|---| -| [`string`] | index signature | [`event:tool/result.data.meta`](#persistence-type-eventtoolresultdatameta) | - ### `event:turn/end` @@ -3937,7 +4003,7 @@ SHA-256: `314765bdff29c7862fb6ce820f1773563ba3094a680d163ea21180a2591b8578` | `data` | required | [`packages/llm/llm/src/message.ts#UserMessage`](#persistence-type-packagesllmllmsrcmessagetsusermessage) | | `ignorable` | optional | `true` | | `seq` | required | `number` | -| `sourceEventSeqs` | optional | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `sourceEventSeqs` | optional | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `surfaceOp` | required | [`packages/core/session/src/types.ts#SurfaceOp`](#persistence-type-packagescoresessionsrctypestssurfaceop) | | `time` | required | `number` | | `type` | required | `"user/message"` | @@ -4482,7 +4548,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:9`](.. | Property | Presence | Type | |---|---|---| -| `imageIndexes` | required | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `imageIndexes` | required | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `seq` | required | `number` | @@ -5642,7 +5708,7 @@ Sources: [`packages/session/session-title-llm/src/index.ts:27`](../packages/sess | Property | Presence | Type | |---|---|---| | `maxTokens` | required | `number` | -| `messageSeqs` | required | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `messageSeqs` | required | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `messages` | required | [`event:session/title-llm-request.data.messages`](#persistence-type-eventsessiontitle-llm-requestdatamessages) | | `route` | required | [`packages/session/session-title/src/types.ts#SessionTitleModelIdentity`](#persistence-type-packagessessionsession-titlesrctypestssessiontitlemodelidentity) | | `system` | required | `string` | @@ -5658,7 +5724,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ | Property | Presence | Type | |---|---|---| -| `messageSeqs` | required | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `messageSeqs` | required | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `source` | required | [`packages/session/session-title/src/types.ts#SessionTitleSource`](#persistence-type-packagessessionsession-titlesrctypestssessiontitlesource) | | `title` | required | `string` | @@ -5808,6 +5874,63 @@ One of: - [`packages/subagent/subagent/src/descriptor.ts#ContinuableSubagentDescriptorData`](#persistence-type-packagessubagentsubagentsrcdescriptortscontinuablesubagentdescriptordata) - [`packages/subagent/subagent/src/descriptor.ts#OneShotSubagentDescriptorData`](#persistence-type-packagessubagentsubagentsrcdescriptortsoneshotsubagentdescriptordata) + + +### `packages/subagent/subagent/src/external-records.ts#ExternalSubagentContent` + +SHA-256: `93647745454737d83fd7e1225f3a815cc88b0258f7857031fddeee388b8c6ea0` + +Sources: [`packages/subagent/subagent/src/external-records.ts:26`](../packages/subagent/subagent/src/external-records.ts) + +| Property | Presence | Type | +|---|---|---| +| `type` | required | `string` | +| [`string`] | index signature | [`event:subagent/external-end.data.result.structured`](#persistence-type-eventsubagentexternal-enddataresultstructured) | + + + +### `packages/subagent/subagent/src/external-records.ts#ExternalSubagentEnd` + +SHA-256: `c3668fe2e95b1c13fda1f778aaeceb98d57edccc16039c3848e6ad2eec86230d` + +Sources: [`packages/subagent/subagent/src/external-records.ts:40`](../packages/subagent/subagent/src/external-records.ts) + +| Property | Presence | Type | +|---|---|---| +| `childId` | required | `string` | +| `result` | required | [`packages/subagent/subagent/src/external-records.ts#ExternalSubagentResult`](#persistence-type-packagessubagentsubagentsrcexternal-recordstsexternalsubagentresult) | +| `version` | required | `0` | + + + +### `packages/subagent/subagent/src/external-records.ts#ExternalSubagentResult` + +SHA-256: `e7506bc518049e7fb22ad0126cb23b5289c910f3e25b54d6d7fa62196b1b6687` + +Sources: [`packages/subagent/subagent/src/external-records.ts:32`](../packages/subagent/subagent/src/external-records.ts) + +| Property | Presence | Type | +|---|---|---| +| `diagnostic` | optional | `string` | +| `output` | required | [`event:subagent/external-end.data.result.output`](#persistence-type-eventsubagentexternal-enddataresultoutput) | +| `stopReason` | required | `string` | +| `structured` | optional | [`event:subagent/external-end.data.result.structured`](#persistence-type-eventsubagentexternal-enddataresultstructured) | + + + +### `packages/subagent/subagent/src/external-records.ts#ExternalSubagentStart` + +SHA-256: `02efe7f06bbceef8ed9c4813f110d7e3fddbc7b378ebdf1d5de15694d87205d6` + +Sources: [`packages/subagent/subagent/src/external-records.ts:18`](../packages/subagent/subagent/src/external-records.ts) + +| Property | Presence | Type | +|---|---|---| +| `childId` | required | `string` | +| `label` | required | `string` | +| `provider` | required | `string` | +| `version` | required | `0` | + ### `packages/todo/tool-todo/src/types.ts#TodoItem` @@ -6191,7 +6314,7 @@ SHA-256: `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` One of: -- [`SessionEventEnvelope[39]`](#persistence-type-sessioneventenvelope39) +- [`SessionEventEnvelope[41]`](#persistence-type-sessioneventenvelope41) - [`SessionEventEnvelope[6]`](#persistence-type-sessioneventenvelope6) - [`SessionEventEnvelope[0]`](#persistence-type-sessioneventenvelope0) @@ -6208,9 +6331,9 @@ SHA-256: `e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f` | `time` | required | `number` | | `type` | required | `string` | - + -### `SessionEventEnvelope[39]` +### `SessionEventEnvelope[41]` SHA-256: `998f22585a5d73ec005365e203f07fc3b3a029e82323fdda3fd7f13b0a9639d0` @@ -6218,14 +6341,14 @@ SHA-256: `998f22585a5d73ec005365e203f07fc3b3a029e82323fdda3fd7f13b0a9639d0` |---|---|---| | `ignorable` | optional | `true` | | `seq` | required | `number` | -| `sourceEventSeqs` | optional | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `sourceEventSeqs` | optional | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `surfaceOp` | required | [`packages/core/session/src/types.ts#SurfaceOp`](#persistence-type-packagescoresessionsrctypestssurfaceop) | | `time` | required | `number` | | `type` | required | `string` | - + -### `SessionEventEnvelope[39].sourceEventSeqs` +### `SessionEventEnvelope[41].sourceEventSeqs` SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` @@ -6373,6 +6496,22 @@ SHA-256: `5ebce1317eacd2c148456627a09668e927ee15c204f0643f5ea76b46b54a2647` `"subagent/descriptor"` + + +### `"subagent/external-end"` + +SHA-256: `32ee1cf94b611ba459e559ef461cfcc9c7df4009fd4f9f4adbb0ac3a624837a0` + +`"subagent/external-end"` + + + +### `"subagent/external-start"` + +SHA-256: `3a10ac79f441a31b4870fbc40df1a0a9678a54b7c498f9c6d4ad14076592fed3` + +`"subagent/external-start"` + ### `"subagent/model-selection-policy"` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 91ca2f7a03..19dca7960d 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -60,6 +60,8 @@ | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`event:step/start`](#persistence-type-eventstepstart) | | `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`event:subagent/catalog`](#persistence-type-eventsubagentcatalog) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`event:subagent/descriptor`](#persistence-type-eventsubagentdescriptor) | +| `event:subagent/external-end` | event | `186d78392e03b505c93e7be6fe6a9eb7025aa1e6401b600f6502d8e7860bf215` | [`event:subagent/external-end`](#persistence-type-eventsubagentexternal-end) | +| `event:subagent/external-start` | event | `e144bc89d55c5f25971eaac9aecddd1549c48912f709af1f7167ae3895dfdf20` | [`event:subagent/external-start`](#persistence-type-eventsubagentexternal-start) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`event:subagent/model-selection-policy`](#persistence-type-eventsubagentmodel-selection-policy) | | `event:system/message` | event | `69becfb6b2d3fd5da91518089454cae8ef33f1835637ec44dde35dd077fd4bae` | [`event:system/message`](#persistence-type-eventsystemmessage) | | `event:team/member` | event | `4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc` | [`event:team/member`](#persistence-type-eventteammember) | @@ -898,6 +900,34 @@ export type SessionEvent = { 来源:[`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) + + +#### `subagent/external-end` — log-only + +```ts persistence-catalog +/** + * An external child settled with its complete retained result. + * @param data - execution identity and terminal result. + */ +'subagent/external-end': ExternalSubagentEnd +``` + +来源:[`packages/subagent/subagent/src/external-records.ts:66`](../packages/subagent/subagent/src/external-records.ts) + + + +#### `subagent/external-start` — log-only + +```ts persistence-catalog +/** + * An external child accepted its initial task without creating a DSH Session. + * @param data - parent-owned execution identity and creation label. + */ +'subagent/external-start': ExternalSubagentStart +``` + +来源:[`packages/subagent/subagent/src/external-records.ts:61`](../packages/subagent/subagent/src/external-records.ts) + #### `subagent/model-selection-policy` — log-only @@ -2118,7 +2148,7 @@ SHA-256: `e72269f931559e901e5e8fe1cfcbd0669fb795f43cc038ff39fba62f60403c33` | 属性 | 存在性 | 类型 | |---|---|---| -| `dt` | 必需 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `dt` | 必需 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `index` | 必需 | `number` | | `texts` | 必需 | [`event:assistant/attempt.data.stream[0][0].texts`](#persistence-type-eventassistantattemptdatastream00texts) | | `time0` | 必需 | `number` | @@ -2142,7 +2172,7 @@ SHA-256: `71bb3254ec8f1e491ce63b762a7b5f18709de23aa4e8abcc8b886f4b6ce051dd` | 属性 | 存在性 | 类型 | |---|---|---| -| `dt` | 必需 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `dt` | 必需 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `index` | 必需 | `number` | | `texts` | 必需 | [`event:assistant/attempt.data.stream[0][0].texts`](#persistence-type-eventassistantattemptdatastream00texts) | | `time0` | 必需 | `number` | @@ -2159,7 +2189,7 @@ SHA-256: `3860b4757d512a2869be833acb0faf834335b1b056bf3f83df5474dce95f8fa6` | 属性 | 存在性 | 类型 | |---|---|---| | `args` | 必需 | [`event:assistant/attempt.data.stream[0][0].texts`](#persistence-type-eventassistantattemptdatastream00texts) | -| `dt` | 必需 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `dt` | 必需 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `id` | 必需 | `string` | | `index` | 必需 | `number` | | `name` | 可选 | `string` | @@ -2517,7 +2547,7 @@ SHA-256: `5dd766ccd1702a1142ad8b4bd44f2242f8e8a6752669ad3de0a5d224329b1438` | 属性 | 存在性 | 类型 | |---|---|---| | `shadowedRange` | 必需 | [`event:compaction/prune.data.shadowedRange`](#persistence-type-eventcompactionprunedatashadowedrange) | -| `shadowedSeqs` | 必需 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `shadowedSeqs` | 必需 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `shadowedTokenCount` | 必需 | `number` | @@ -2601,7 +2631,7 @@ SHA-256: `9ff2ad8dddb836f75e8f6197fff2371323dfc396c3c6fb667e6e0b42fda79a5c` | `provider` | 必需 | `string` | | `rawOutput` | 必需 | [`event:agent/inbox/spliced.data.inserted[0].content`](#persistence-type-eventagentinboxspliceddatainserted0content) | | `shadowedRange` | 必需 | [`event:compaction/prune.data.shadowedRange`](#persistence-type-eventcompactionprunedatashadowedrange) | -| `shadowedSeqs` | 必需 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `shadowedSeqs` | 必需 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `shadowedTokenCount` | 必需 | `number` | | `sourceCommandId` | 可选 | `string` | | `summary` | 必需 | [`event:agent/inbox/spliced.data.inserted[0].content`](#persistence-type-eventagentinboxspliceddatainserted0content) | @@ -2621,7 +2651,7 @@ SHA-256: `3aa261c8516b5c955ed12080e6257a56cbe513f6c9f0ac9274c12e96c196ebca` | `provider` | 必需 | `string` | | `rawOutput` | 可选 | [`event:agent/inbox/spliced.data.inserted[0].content`](#persistence-type-eventagentinboxspliceddatainserted0content) | | `shadowedRange` | 必需 | [`event:compaction/prune.data.shadowedRange`](#persistence-type-eventcompactionprunedatashadowedrange) | -| `shadowedSeqs` | 必需 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `shadowedSeqs` | 必需 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `shadowedTokenCount` | 必需 | `number` | | `sourceCommandId` | 可选 | `string` | | `summary` | 必需 | [`event:agent/inbox/spliced.data.inserted[0].content`](#persistence-type-eventagentinboxspliceddatainserted0content) | @@ -3307,6 +3337,79 @@ SHA-256: `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | `time` | 必需 | `number` | | `type` | 必需 | `"subagent/descriptor"` | + + +### `event:subagent/external-end` + +SHA-256: `186d78392e03b505c93e7be6fe6a9eb7025aa1e6401b600f6502d8e7860bf215` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `data` | 必需 | [`packages/subagent/subagent/src/external-records.ts#ExternalSubagentEnd`](#persistence-type-packagessubagentsubagentsrcexternal-recordstsexternalsubagentend) | +| `ignorable` | 可选 | `true` | +| `seq` | 必需 | `number` | +| `time` | 必需 | `number` | +| `type` | 必需 | `"subagent/external-end"` | + + + +### `event:subagent/external-end.data.result.output` + +SHA-256: `3766703bc2634fec566d283ed6b2d794722f2cd9b02b65ce39c11d374d3a28d1` + +[`packages/subagent/subagent/src/external-records.ts#ExternalSubagentContent`](#persistence-type-packagessubagentsubagentsrcexternal-recordstsexternalsubagentcontent) 的数组。 + + + +### `event:subagent/external-end.data.result.output[0].[value][5]` + +SHA-256: `72ec79127a9c0d0241b1106a74cc2b24c81ce467170f1c3c93f7b71a9496d227` + +[`event:subagent/external-end.data.result.structured`](#persistence-type-eventsubagentexternal-enddataresultstructured) 的数组。 + + + +### `event:subagent/external-end.data.result.output[0].[value][6]` + +SHA-256: `529e0ccd0e34079ab4ff21ed87f810cee0388246263bf22e8aba5986b56fae05` + +来源:[`packages/util/values/src/index.ts:4`](../packages/util/values/src/index.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| [`string`] | 索引签名 | [`event:subagent/external-end.data.result.structured`](#persistence-type-eventsubagentexternal-enddataresultstructured) | + + + +### `event:subagent/external-end.data.result.structured` + +SHA-256: `c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6` + +来源:[`packages/util/values/src/index.ts:4`](../packages/util/values/src/index.ts) + +以下类型之一: + +- [`event:subagent/external-end.data.result.output[0].[value][5]`](#persistence-type-eventsubagentexternal-enddataresultoutput0value5) +- [`event:subagent/external-end.data.result.output[0].[value][6]`](#persistence-type-eventsubagentexternal-enddataresultoutput0value6) +- `string` +- `boolean` +- `null` +- `number` + + + +### `event:subagent/external-start` + +SHA-256: `e144bc89d55c5f25971eaac9aecddd1549c48912f709af1f7167ae3895dfdf20` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `data` | 必需 | [`packages/subagent/subagent/src/external-records.ts#ExternalSubagentStart`](#persistence-type-packagessubagentsubagentsrcexternal-recordstsexternalsubagentstart) | +| `ignorable` | 可选 | `true` | +| `seq` | 必需 | `number` | +| `time` | 必需 | `number` | +| `type` | 必需 | `"subagent/external-start"` | + ### `event:subagent/model-selection-policy` @@ -3352,7 +3455,7 @@ SHA-256: `69becfb6b2d3fd5da91518089454cae8ef33f1835637ec44dde35dd077fd4bae` | `data` | 必需 | [`event:system/message.data`](#persistence-type-eventsystemmessagedata) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | -| `sourceEventSeqs` | 可选 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `sourceEventSeqs` | 可选 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `surfaceOp` | 必需 | [`packages/core/session/src/types.ts#SurfaceOp`](#persistence-type-packagescoresessionsrctypestssurfaceop) | | `time` | 必需 | `number` | | `type` | 必需 | `"system/message"` | @@ -3696,7 +3799,7 @@ SHA-256: `29af48b840d0cd9e48b6f50bf3b354f8f6340607c5b99220a48e74f60beac9e2` | `data` | 必需 | [`event:tool/result.data`](#persistence-type-eventtoolresultdata) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | -| `sourceEventSeqs` | 可选 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `sourceEventSeqs` | 可选 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `surfaceOp` | 必需 | [`packages/core/session/src/types.ts#SurfaceOp`](#persistence-type-packagescoresessionsrctypestssurfaceop) | | `time` | 必需 | `number` | | `type` | 必需 | `"tool/result"` | @@ -3713,7 +3816,7 @@ SHA-256: `356aad27134a0e31af67c994ff8b9e4b91acfb6c4917b3bc1f7a62bbf82794d5` |---|---|---| | `error` | 可选 | [`event:tool/ptc-dispatch.data.error`](#persistence-type-eventtoolptc-dispatchdataerror) | | `message` | 必需 | [`packages/llm/llm/src/message.ts#ToolResultMessage`](#persistence-type-packagesllmllmsrcmessagetstoolresultmessage) | -| `meta` | 可选 | [`event:tool/result.data.meta`](#persistence-type-eventtoolresultdatameta) | +| `meta` | 可选 | [`event:subagent/external-end.data.result.structured`](#persistence-type-eventsubagentexternal-enddataresultstructured) | | `step` | 必需 | `number` | | `turn` | 必需 | `number` | @@ -3727,43 +3830,6 @@ SHA-256: `8d6a011f3ef7096a4a922ef420458419ff6b20e1eaeece5d331db7585d2f08d6` |---|---|---| | 0 | 必需 | [`packages/llm/llm/src/types.ts#ToolResultBlock`](#persistence-type-packagesllmllmsrctypeststoolresultblock) | - - -### `event:tool/result.data.meta` - -SHA-256: `c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6` - -来源:[`packages/util/values/src/index.ts:4`](../packages/util/values/src/index.ts) - -以下类型之一: - -- [`event:tool/result.data.meta[5]`](#persistence-type-eventtoolresultdatameta5) -- [`event:tool/result.data.meta[5][0][6]`](#persistence-type-eventtoolresultdatameta506) -- `string` -- `boolean` -- `null` -- `number` - - - -### `event:tool/result.data.meta[5]` - -SHA-256: `72ec79127a9c0d0241b1106a74cc2b24c81ce467170f1c3c93f7b71a9496d227` - -[`event:tool/result.data.meta`](#persistence-type-eventtoolresultdatameta) 的数组。 - - - -### `event:tool/result.data.meta[5][0][6]` - -SHA-256: `529e0ccd0e34079ab4ff21ed87f810cee0388246263bf22e8aba5986b56fae05` - -来源:[`packages/util/values/src/index.ts:4`](../packages/util/values/src/index.ts) - -| 属性 | 存在性 | 类型 | -|---|---|---| -| [`string`] | 索引签名 | [`event:tool/result.data.meta`](#persistence-type-eventtoolresultdatameta) | - ### `event:turn/end` @@ -3939,7 +4005,7 @@ SHA-256: `314765bdff29c7862fb6ce820f1773563ba3094a680d163ea21180a2591b8578` | `data` | 必需 | [`packages/llm/llm/src/message.ts#UserMessage`](#persistence-type-packagesllmllmsrcmessagetsusermessage) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | -| `sourceEventSeqs` | 可选 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `sourceEventSeqs` | 可选 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `surfaceOp` | 必需 | [`packages/core/session/src/types.ts#SurfaceOp`](#persistence-type-packagescoresessionsrctypestssurfaceop) | | `time` | 必需 | `number` | | `type` | 必需 | `"user/message"` | @@ -4484,7 +4550,7 @@ SHA-256: `1cde1bedde79c5e8a56aee9c05c8e0a579cf5bc611deb85b93fa331a16ca978f` | 属性 | 存在性 | 类型 | |---|---|---| -| `imageIndexes` | 必需 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `imageIndexes` | 必需 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `seq` | 必需 | `number` | @@ -5644,7 +5710,7 @@ SHA-256: `9fe0e77a7816b7fdb4b953ec0e2f0cd7bbd22c5de5c3567e612d1b20eb0992e6` | 属性 | 存在性 | 类型 | |---|---|---| | `maxTokens` | 必需 | `number` | -| `messageSeqs` | 必需 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `messageSeqs` | 必需 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `messages` | 必需 | [`event:session/title-llm-request.data.messages`](#persistence-type-eventsessiontitle-llm-requestdatamessages) | | `route` | 必需 | [`packages/session/session-title/src/types.ts#SessionTitleModelIdentity`](#persistence-type-packagessessionsession-titlesrctypestssessiontitlemodelidentity) | | `system` | 必需 | `string` | @@ -5660,7 +5726,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` | 属性 | 存在性 | 类型 | |---|---|---| -| `messageSeqs` | 必需 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `messageSeqs` | 必需 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `source` | 必需 | [`packages/session/session-title/src/types.ts#SessionTitleSource`](#persistence-type-packagessessionsession-titlesrctypestssessiontitlesource) | | `title` | 必需 | `string` | @@ -5810,6 +5876,63 @@ SHA-256: `86d1d0e9cfebb240421187e3dc37fb71cc38a1501885cf32f8f9adef7b0a2bde` - [`packages/subagent/subagent/src/descriptor.ts#ContinuableSubagentDescriptorData`](#persistence-type-packagessubagentsubagentsrcdescriptortscontinuablesubagentdescriptordata) - [`packages/subagent/subagent/src/descriptor.ts#OneShotSubagentDescriptorData`](#persistence-type-packagessubagentsubagentsrcdescriptortsoneshotsubagentdescriptordata) + + +### `packages/subagent/subagent/src/external-records.ts#ExternalSubagentContent` + +SHA-256: `93647745454737d83fd7e1225f3a815cc88b0258f7857031fddeee388b8c6ea0` + +来源:[`packages/subagent/subagent/src/external-records.ts:26`](../packages/subagent/subagent/src/external-records.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `type` | 必需 | `string` | +| [`string`] | 索引签名 | [`event:subagent/external-end.data.result.structured`](#persistence-type-eventsubagentexternal-enddataresultstructured) | + + + +### `packages/subagent/subagent/src/external-records.ts#ExternalSubagentEnd` + +SHA-256: `c3668fe2e95b1c13fda1f778aaeceb98d57edccc16039c3848e6ad2eec86230d` + +来源:[`packages/subagent/subagent/src/external-records.ts:40`](../packages/subagent/subagent/src/external-records.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childId` | 必需 | `string` | +| `result` | 必需 | [`packages/subagent/subagent/src/external-records.ts#ExternalSubagentResult`](#persistence-type-packagessubagentsubagentsrcexternal-recordstsexternalsubagentresult) | +| `version` | 必需 | `0` | + + + +### `packages/subagent/subagent/src/external-records.ts#ExternalSubagentResult` + +SHA-256: `e7506bc518049e7fb22ad0126cb23b5289c910f3e25b54d6d7fa62196b1b6687` + +来源:[`packages/subagent/subagent/src/external-records.ts:32`](../packages/subagent/subagent/src/external-records.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `diagnostic` | 可选 | `string` | +| `output` | 必需 | [`event:subagent/external-end.data.result.output`](#persistence-type-eventsubagentexternal-enddataresultoutput) | +| `stopReason` | 必需 | `string` | +| `structured` | 可选 | [`event:subagent/external-end.data.result.structured`](#persistence-type-eventsubagentexternal-enddataresultstructured) | + + + +### `packages/subagent/subagent/src/external-records.ts#ExternalSubagentStart` + +SHA-256: `02efe7f06bbceef8ed9c4813f110d7e3fddbc7b378ebdf1d5de15694d87205d6` + +来源:[`packages/subagent/subagent/src/external-records.ts:18`](../packages/subagent/subagent/src/external-records.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childId` | 必需 | `string` | +| `label` | 必需 | `string` | +| `provider` | 必需 | `string` | +| `version` | 必需 | `0` | + ### `packages/todo/tool-todo/src/types.ts#TodoItem` @@ -6193,7 +6316,7 @@ SHA-256: `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` 以下类型之一: -- [`SessionEventEnvelope[39]`](#persistence-type-sessioneventenvelope39) +- [`SessionEventEnvelope[41]`](#persistence-type-sessioneventenvelope41) - [`SessionEventEnvelope[6]`](#persistence-type-sessioneventenvelope6) - [`SessionEventEnvelope[0]`](#persistence-type-sessioneventenvelope0) @@ -6210,9 +6333,9 @@ SHA-256: `e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f` | `time` | 必需 | `number` | | `type` | 必需 | `string` | - + -### `SessionEventEnvelope[39]` +### `SessionEventEnvelope[41]` SHA-256: `998f22585a5d73ec005365e203f07fc3b3a029e82323fdda3fd7f13b0a9639d0` @@ -6220,14 +6343,14 @@ SHA-256: `998f22585a5d73ec005365e203f07fc3b3a029e82323fdda3fd7f13b0a9639d0` |---|---|---| | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | -| `sourceEventSeqs` | 可选 | [`SessionEventEnvelope[39].sourceEventSeqs`](#persistence-type-sessioneventenvelope39sourceeventseqs) | +| `sourceEventSeqs` | 可选 | [`SessionEventEnvelope[41].sourceEventSeqs`](#persistence-type-sessioneventenvelope41sourceeventseqs) | | `surfaceOp` | 必需 | [`packages/core/session/src/types.ts#SurfaceOp`](#persistence-type-packagescoresessionsrctypestssurfaceop) | | `time` | 必需 | `number` | | `type` | 必需 | `string` | - + -### `SessionEventEnvelope[39].sourceEventSeqs` +### `SessionEventEnvelope[41].sourceEventSeqs` SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` @@ -6375,6 +6498,22 @@ SHA-256: `5ebce1317eacd2c148456627a09668e927ee15c204f0643f5ea76b46b54a2647` `"subagent/descriptor"` + + +### `"subagent/external-end"` + +SHA-256: `32ee1cf94b611ba459e559ef461cfcc9c7df4009fd4f9f4adbb0ac3a624837a0` + +`"subagent/external-end"` + + + +### `"subagent/external-start"` + +SHA-256: `3a10ac79f441a31b4870fbc40df1a0a9678a54b7c498f9c6d4ad14076592fed3` + +`"subagent/external-start"` + ### `"subagent/model-selection-policy"` diff --git a/docs/persistence-changes/2026-09-17-external-subagent-activations.i18n.yaml b/docs/persistence-changes/2026-09-17-external-subagent-activations.i18n.yaml new file mode 100644 index 0000000000..50c65971ff --- /dev/null +++ b/docs/persistence-changes/2026-09-17-external-subagent-activations.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/persistence-changes/2026-09-17-external-subagent-activations.md +2026-09-17-external-subagent-activations.md: b7f7e527513abacd18028ea78a7a52d4cc370888 +2026-09-17-external-subagent-activations.zh.md: a72c849d461689578e98ae9dff82632e9c8dd101 diff --git a/docs/persistence-changes/2026-09-17-external-subagent-activations.md b/docs/persistence-changes/2026-09-17-external-subagent-activations.md new file mode 100644 index 0000000000..b7f7e52751 --- /dev/null +++ b/docs/persistence-changes/2026-09-17-external-subagent-activations.md @@ -0,0 +1,52 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-09-17-external-subagent-activations + +English | [中文](2026-09-17-external-subagent-activations.zh.md) + +## Summary + +Records external subagent identities and complete execution results in their parent Session. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-17-external-subagent-activations +baseline: false +changes: + - root: "event:subagent/external-end" + previous: null + after: "186d78392e03b505c93e7be6fe6a9eb7025aa1e6401b600f6502d8e7860bf215" + decision: same-version + - root: "event:subagent/external-start" + previous: null + after: "e144bc89d55c5f25971eaac9aecddd1549c48912f709af1f7167ae3895dfdf20" + decision: same-version +``` + + +## Compatibility + +Adds version-0 subagent/external-start and subagent/external-end events without changing existing events or Session headers. Existing logs remain readable. Builds without these required event types reject logs containing them. External children have no local child Session; the parent projection retains JSON output, structured values, diagnostics, and the final stop reason. + + +## Verification + +External records: 8 focused tests passed, including JSONL cold reconstruction, inherited-prefix exclusion, invalid pairing, and JSON validation. External activation: 7 tests passed, including startup cancellation, result-before-cleanup, idempotent disposal, automatic reporting, and preservation of returned output after cleanup failure. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-09-17-external-subagent-activations.schema.json b/docs/persistence-changes/2026-09-17-external-subagent-activations.schema.json new file mode 100644 index 0000000000..85900ad852 --- /dev/null +++ b/docs/persistence-changes/2026-09-17-external-subagent-activations.schema.json @@ -0,0 +1,258 @@ +{ + "formatVersion": 1, + "roots": [ + { + "key": "event:subagent/external-end", + "kind": "event", + "event": "subagent/external-end", + "surface": false, + "digest": "186d78392e03b505c93e7be6fe6a9eb7025aa1e6401b600f6502d8e7860bf215", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 13, + "optional": true + }, + { + "name": "seq", + "type": 10, + "optional": false + }, + { + "name": "time", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 14, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "result", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 12, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "object", + "properties": [ + { + "name": "diagnostic", + "type": 2, + "optional": true + }, + { + "name": "output", + "type": 4, + "optional": false + }, + { + "name": "stopReason", + "type": 2, + "optional": false + }, + { + "name": "structured", + "type": 6, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "object", + "properties": [ + { + "name": "type", + "type": 2, + "optional": false + } + ], + "indices": [ + { + "key": 2, + "value": 6 + } + ] + }, + { + "kind": "union", + "types": [ + 2, + 7, + 8, + 9, + 10, + 11 + ] + }, + { + "kind": "array", + "element": 6 + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "object", + "properties": [], + "indices": [ + { + "key": 2, + "value": 6 + } + ] + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/external-end" + } + ] + } + }, + { + "key": "event:subagent/external-start", + "kind": "event", + "event": "subagent/external-start", + "surface": false, + "digest": "e144bc89d55c5f25971eaac9aecddd1549c48912f709af1f7167ae3895dfdf20", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 4, + "optional": true + }, + { + "name": "seq", + "type": 5, + "optional": false + }, + { + "name": "time", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": false + }, + { + "name": "provider", + "type": 2, + "optional": false + }, + { + "name": "version", + "type": 3, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "literal", + "value": "subagent/external-start" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-09-17-external-subagent-activations.zh.md b/docs/persistence-changes/2026-09-17-external-subagent-activations.zh.md new file mode 100644 index 0000000000..a72c849d46 --- /dev/null +++ b/docs/persistence-changes/2026-09-17-external-subagent-activations.zh.md @@ -0,0 +1,52 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-09-17-external-subagent-activations + +[English](2026-09-17-external-subagent-activations.md) | 中文 + +## 概述 + +在父 Session 中记录外部子代理身份和完整执行结果。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-17-external-subagent-activations +baseline: false +changes: + - root: "event:subagent/external-end" + previous: null + after: "186d78392e03b505c93e7be6fe6a9eb7025aa1e6401b600f6502d8e7860bf215" + decision: same-version + - root: "event:subagent/external-start" + previous: null + after: "e144bc89d55c5f25971eaac9aecddd1549c48912f709af1f7167ae3895dfdf20" + decision: same-version +``` + + +## 兼容性 + +新增版本为 0 的 subagent/external-start 和 subagent/external-end 事件,不修改已有事件或 Session 头。已有日志仍可读取。不认识这些必读事件类型的构建会拒绝包含它们的日志。外部子代理没有本地子 Session;父投影保留 JSON 输出、结构化值、诊断和最终停止原因。 + + +## 验证 + +外部记录的 8 个聚焦测试通过,覆盖 JSONL 冷读重建、继承前缀排除、错误配对和 JSON 校验。外部 activation 的 7 个测试通过,覆盖启动取消、结果先于清理、幂等释放、自动回传,以及清理失败后保留已返回输出。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index b60b927322..de40c0ec9a 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/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 docs/persistence-changes/historical-formats/README.md -README.md: fda6ac3b4fc3e52ef215dc0a2fca00576cd4ef0c -README.zh.md: 792f47e532d6ebfdbb745c1bc2703c7b5f4fcc17 +README.md: eb83bb949d3a9337874e0a1f1540969b3efebb06 +README.zh.md: ddcdb9915e5bc480d543650a28fb06997ff7f4e5 diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index fda6ac3b4f..eb83bb949d 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -31,7 +31,7 @@ The index is generated from validated snapshots and the current writer constant. | 0 | `dsh-v0.1.2-rc.1` | [V0](v0.md) | [JSON](v0.schema.json) | 54 / 415 | | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | -| 3 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 61 / 470 | +| 3 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 63 / 479 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 792f47e532..ddcdb9915e 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -31,7 +31,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 0 | `dsh-v0.1.2-rc.1` | [V0](v0.zh.md) | [JSON](v0.schema.json) | 54 / 415 | | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | -| 3 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 61 / 470 | +| 3 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 63 / 479 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 8649288be2..8742cb5fce 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -8925,6 +8925,258 @@ ] } }, + { + "key": "event:subagent/external-end", + "kind": "event", + "event": "subagent/external-end", + "surface": false, + "digest": "186d78392e03b505c93e7be6fe6a9eb7025aa1e6401b600f6502d8e7860bf215", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 13, + "optional": true + }, + { + "name": "seq", + "type": 10, + "optional": false + }, + { + "name": "time", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 14, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "result", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 12, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "object", + "properties": [ + { + "name": "diagnostic", + "type": 2, + "optional": true + }, + { + "name": "output", + "type": 4, + "optional": false + }, + { + "name": "stopReason", + "type": 2, + "optional": false + }, + { + "name": "structured", + "type": 6, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "object", + "properties": [ + { + "name": "type", + "type": 2, + "optional": false + } + ], + "indices": [ + { + "key": 2, + "value": 6 + } + ] + }, + { + "kind": "union", + "types": [ + 2, + 7, + 8, + 9, + 10, + 11 + ] + }, + { + "kind": "array", + "element": 6 + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "object", + "properties": [], + "indices": [ + { + "key": 2, + "value": 6 + } + ] + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/external-end" + } + ] + } + }, + { + "key": "event:subagent/external-start", + "kind": "event", + "event": "subagent/external-start", + "surface": false, + "digest": "e144bc89d55c5f25971eaac9aecddd1549c48912f709af1f7167ae3895dfdf20", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 4, + "optional": true + }, + { + "name": "seq", + "type": 5, + "optional": false + }, + { + "name": "time", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": false + }, + { + "name": "provider", + "type": 2, + "optional": false + }, + { + "name": "version", + "type": 3, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "literal", + "value": "subagent/external-start" + } + ] + } + }, { "key": "event:subagent/model-selection-policy", "kind": "event", @@ -13854,6 +14106,54 @@ "packages/core/session/src/types.ts:205" ] }, + { + "digest": "02efe7f06bbceef8ed9c4813f110d7e3fddbc7b378ebdf1d5de15694d87205d6", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childId", + "type": 1, + "optional": false + }, + { + "name": "label", + "type": 1, + "optional": false + }, + { + "name": "provider", + "type": 1, + "optional": false + }, + { + "name": "version", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": 0 + } + ] + }, + "names": [ + "packages/subagent/subagent/src/external-records.ts#ExternalSubagentStart" + ], + "sources": [ + "packages/subagent/subagent/src/external-records.ts:18" + ] + }, { "digest": "0341d4c3dfcd53253cdc1e789d9130d3cd848b6f8b0706bf92f313a68b39ff23", "schema": { @@ -18087,6 +18387,169 @@ "packages/core/session/src/types.ts:405" ] }, + { + "digest": "186d78392e03b505c93e7be6fe6a9eb7025aa1e6401b600f6502d8e7860bf215", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 13, + "optional": true + }, + { + "name": "seq", + "type": 10, + "optional": false + }, + { + "name": "time", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 14, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "result", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 12, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "object", + "properties": [ + { + "name": "diagnostic", + "type": 2, + "optional": true + }, + { + "name": "output", + "type": 4, + "optional": false + }, + { + "name": "stopReason", + "type": 2, + "optional": false + }, + { + "name": "structured", + "type": 6, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "object", + "properties": [ + { + "name": "type", + "type": 2, + "optional": false + } + ], + "indices": [ + { + "key": 2, + "value": 6 + } + ] + }, + { + "kind": "union", + "types": [ + 2, + 7, + 8, + 9, + 10, + 11 + ] + }, + { + "kind": "array", + "element": 6 + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "object", + "properties": [], + "indices": [ + { + "key": 2, + "value": 6 + } + ] + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/external-end" + } + ] + }, + "names": [ + "event:subagent/external-end" + ], + "sources": [] + }, { "digest": "18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0", "schema": { @@ -26978,6 +27441,20 @@ "packages/llm/llm/src/call-config.ts:36" ] }, + { + "digest": "32ee1cf94b611ba459e559ef461cfcc9c7df4009fd4f9f4adbb0ac3a624837a0", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "literal", + "value": "subagent/external-end" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "3305a4bb6b6c29b482ba91d59c60b47e8f98282d3dacc8c29e4a4c2712209bdd", "schema": { @@ -27840,6 +28317,79 @@ "packages/llm/llm-retry/src/types.ts:30" ] }, + { + "digest": "3766703bc2634fec566d283ed6b2d794722f2cd9b02b65ce39c11d374d3a28d1", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "array", + "element": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "type", + "type": 2, + "optional": false + } + ], + "indices": [ + { + "key": 2, + "value": 3 + } + ] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "union", + "types": [ + 2, + 4, + 5, + 6, + 7, + 8 + ] + }, + { + "kind": "array", + "element": 3 + }, + { + "kind": "object", + "properties": [], + "indices": [ + { + "key": 2, + "value": 3 + } + ] + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "number" + } + ] + }, + "names": [ + "event:subagent/external-end.data.result.output" + ], + "sources": [] + }, { "digest": "378f90f9f6022a0991928a1eaf7991094ea1920fe89a9248358a1da106e1a970", "schema": { @@ -28061,6 +28611,20 @@ "packages/feedback/message-feedback/src/types.ts:17" ] }, + { + "digest": "3a10ac79f441a31b4870fbc40df1a0a9678a54b7c498f9c6d4ad14076592fed3", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "literal", + "value": "subagent/external-start" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "3aa261c8516b5c955ed12080e6257a56cbe513f6c9f0ac9274c12e96c196ebca", "schema": { @@ -34098,7 +34662,8 @@ ] }, "names": [ - "event:tool/result.data.meta[5][0][6]", + "event:subagent/external-end.data.result.output[0].[value][6]", + "event:subagent/external-end.data.result.structured[6]", "event:tool/result.data.meta[6]" ], "sources": [ @@ -35076,9 +35641,9 @@ ] }, "names": [ - "SessionEventEnvelope[39].sourceEventSeqs", - "SessionEventEnvelope[52].sourceEventSeqs", - "SessionEventEnvelope[55].sourceEventSeqs", + "SessionEventEnvelope[41].sourceEventSeqs", + "SessionEventEnvelope[54].sourceEventSeqs", + "SessionEventEnvelope[57].sourceEventSeqs", "event:assistant/attempt.data.stream[0][0].dt", "event:assistant/attempt.data.stream[0][1].dt", "event:assistant/attempt.data.stream[0][2].dt", @@ -38751,8 +39316,9 @@ ] }, "names": [ - "event:tool/result.data.meta[5]", - "event:tool/result.data.meta[5][0][5]" + "event:subagent/external-end.data.result.output[0].[value][5]", + "event:subagent/external-end.data.result.structured[5]", + "event:tool/result.data.meta[5]" ], "sources": [] }, @@ -44191,6 +44757,77 @@ "names": [], "sources": [] }, + { + "digest": "93647745454737d83fd7e1225f3a815cc88b0258f7857031fddeee388b8c6ea0", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "type", + "type": 1, + "optional": false + } + ], + "indices": [ + { + "key": 1, + "value": 2 + } + ] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "union", + "types": [ + 1, + 3, + 4, + 5, + 6, + 7 + ] + }, + { + "kind": "array", + "element": 2 + }, + { + "kind": "object", + "properties": [], + "indices": [ + { + "key": 1, + "value": 2 + } + ] + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "number" + } + ] + }, + "names": [ + "packages/subagent/subagent/src/external-records.ts#ExternalSubagentContent" + ], + "sources": [ + "packages/subagent/subagent/src/external-records.ts:26" + ] + }, { "digest": "938f7c7357bc403ea3b281a9dd016939df7a0880fc8db12612585be574d0d615", "schema": { @@ -44559,9 +45196,9 @@ ] }, "names": [ - "SessionEventEnvelope[39]", - "SessionEventEnvelope[52]", - "SessionEventEnvelope[55]" + "SessionEventEnvelope[41]", + "SessionEventEnvelope[54]", + "SessionEventEnvelope[57]" ], "sources": [] }, @@ -52633,6 +53270,132 @@ "packages/workflow/workflow/src/types.ts:110" ] }, + { + "digest": "c3668fe2e95b1c13fda1f778aaeceb98d57edccc16039c3848e6ad2eec86230d", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childId", + "type": 1, + "optional": false + }, + { + "name": "result", + "type": 2, + "optional": false + }, + { + "name": "version", + "type": 11, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "object", + "properties": [ + { + "name": "diagnostic", + "type": 1, + "optional": true + }, + { + "name": "output", + "type": 3, + "optional": false + }, + { + "name": "stopReason", + "type": 1, + "optional": false + }, + { + "name": "structured", + "type": 5, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 4 + }, + { + "kind": "object", + "properties": [ + { + "name": "type", + "type": 1, + "optional": false + } + ], + "indices": [ + { + "key": 1, + "value": 5 + } + ] + }, + { + "kind": "union", + "types": [ + 1, + 6, + 7, + 8, + 9, + 10 + ] + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "object", + "properties": [], + "indices": [ + { + "key": 1, + "value": 5 + } + ] + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "literal", + "value": 0 + } + ] + }, + "names": [ + "packages/subagent/subagent/src/external-records.ts#ExternalSubagentEnd" + ], + "sources": [ + "packages/subagent/subagent/src/external-records.ts:40" + ] + }, { "digest": "c43b119ce579b3e9983a0d3adc4ba402ebb9ba773d89738a2dab01ecf5813943", "schema": { @@ -52852,6 +53615,7 @@ ] }, "names": [ + "event:subagent/external-end.data.result.structured", "event:tool/result.data.meta", "packages/util/values/src/index.ts#JsonValue" ], @@ -58171,6 +58935,95 @@ ], "sources": [] }, + { + "digest": "e144bc89d55c5f25971eaac9aecddd1549c48912f709af1f7167ae3895dfdf20", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 4, + "optional": true + }, + { + "name": "seq", + "type": 5, + "optional": false + }, + { + "name": "time", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": false + }, + { + "name": "provider", + "type": 2, + "optional": false + }, + { + "name": "version", + "type": 3, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "literal", + "value": "subagent/external-start" + } + ] + }, + "names": [ + "event:subagent/external-start" + ], + "sources": [] + }, { "digest": "e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1", "schema": { @@ -58587,9 +59440,9 @@ "SessionEventEnvelope[36]", "SessionEventEnvelope[37]", "SessionEventEnvelope[38]", + "SessionEventEnvelope[39]", "SessionEventEnvelope[3]", "SessionEventEnvelope[40]", - "SessionEventEnvelope[41]", "SessionEventEnvelope[42]", "SessionEventEnvelope[43]", "SessionEventEnvelope[44]", @@ -58601,10 +59454,12 @@ "SessionEventEnvelope[4]", "SessionEventEnvelope[50]", "SessionEventEnvelope[51]", + "SessionEventEnvelope[52]", "SessionEventEnvelope[53]", - "SessionEventEnvelope[54]", + "SessionEventEnvelope[55]", "SessionEventEnvelope[56]", - "SessionEventEnvelope[57]", + "SessionEventEnvelope[58]", + "SessionEventEnvelope[59]", "SessionEventEnvelope[5]", "SessionEventEnvelope[7]", "SessionEventEnvelope[8]", @@ -59251,6 +60106,107 @@ "packages/llm/llm/src/assistant-stream.ts:21" ] }, + { + "digest": "e7506bc518049e7fb22ad0126cb23b5289c910f3e25b54d6d7fa62196b1b6687", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "diagnostic", + "type": 1, + "optional": true + }, + { + "name": "output", + "type": 2, + "optional": false + }, + { + "name": "stopReason", + "type": 1, + "optional": false + }, + { + "name": "structured", + "type": 4, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "array", + "element": 3 + }, + { + "kind": "object", + "properties": [ + { + "name": "type", + "type": 1, + "optional": false + } + ], + "indices": [ + { + "key": 1, + "value": 4 + } + ] + }, + { + "kind": "union", + "types": [ + 1, + 5, + 6, + 7, + 8, + 9 + ] + }, + { + "kind": "array", + "element": 4 + }, + { + "kind": "object", + "properties": [], + "indices": [ + { + "key": 1, + "value": 4 + } + ] + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "number" + } + ] + }, + "names": [ + "packages/subagent/subagent/src/external-records.ts#ExternalSubagentResult" + ], + "sources": [ + "packages/subagent/subagent/src/external-records.ts:32" + ] + }, { "digest": "e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4", "schema": { diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 20d4e50962..fbcf303c79 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.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/subsystems/jobs.md -jobs.md: 4be335207b7dd94f08015ecda595a6b1cc301a7f -jobs.zh.md: 9dc390eb30901664edb47c6727a54ddd83839fa7 +jobs.md: 4e82f5ed852709eefefa44ac1ed683965c5e3acd +jobs.zh.md: 25a3a7db5d9f08150bde430e6e16b1616f2eb306 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 4be335207b..4e82f5ed85 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -15,7 +15,6 @@ Types shared by long-running producers, `ctx.jobs`, and job controls. The [runti */ interface JobKindMap { bash: 'bash' - subagent: 'subagent' } ``` @@ -32,7 +31,7 @@ interface JobKindMap { * execution resources while the runtime owns identity and lifecycle state. */ interface JobStart { - /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ + /** Producer kind — also the id prefix (`bash`, …). */ kind: JobKind /** One-line model-facing label (the command; the delegation description). */ label: string diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 9dc390eb30..25a3a7db5d 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -15,7 +15,6 @@ */ interface JobKindMap { bash: 'bash' - subagent: 'subagent' } ``` @@ -32,7 +31,7 @@ interface JobKindMap { * execution resources while the runtime owns identity and lifecycle state. */ interface JobStart { - /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ + /** Producer kind — also the id prefix (`bash`, …). */ kind: JobKind /** One-line model-facing label (the command; the delegation description). */ label: string diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index d998951e48..ba033d9509 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/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/subsystems/subagent.md -subagent.md: 2bdb618f2196e5593fcdd4448504165a641fd23e -subagent.zh.md: 0fbde9283617cf04907f830cc8486aef284eab68 +subagent.md: 43f8befa9d82e1b74ae830093f96d53d7b46e382 +subagent.zh.md: 799aa100ae4c841aa7a4370a0cbadc61ea6f643a diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 2bdb618f21..43f8befa9d 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -1,30 +1,19 @@ -# Subagent +# Subagents English | [中文](subagent.zh.md) -The subagent seam lets an agent delegate work to a child agent. Like [bash](shell.md), it is **one optional capability**, not part of the agent loop, so its types live here rather than in [core.md](core.md). It differs from the other capability seams because **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), while bash allows only one executor. Its registry follows the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. +The unified activation API manages local conversations and external executions. -Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Service Providers are sibling packages (`dsh-subagent-spawn-in-process`, `dsh-subagent-fork-in-process`, `dsh-subagent-acp`, `dsh-subagent-codex`, `dsh-subagent-claude-code`, `dsh-subagent-dsh-sdk`); 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`, `interrupt_agent`, and `list_agents` controls). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only child and descendant discovery straight from the session store and optional session persistence. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam 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 adjacent-Agent messaging Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md); [the archived list-identity-projection record](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md) documents the original list-identity decision. +## Capabilities and activation requests -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) - -The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. - -## Two kinds of capability, discovered two ways - -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-contract-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-contract-subagentprovider). +Every backend uses startActivation. Capability flags validate request options; prepareContinuable selects the local driver, while start selects one external execution. The model-facing tool uses parent delivery. Workflow and code mode consumers can await caller results without injecting another parent message. ```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 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. + * Start-time options supported by a registered backend. The manager checks + * every requested option before preparing a local child or starting an external + * execution. depthLimit controls maxDepth; the remaining flag names match + * SubagentStartRequest fields. */ interface SubagentCapabilities { readonly agentOptions: boolean @@ -35,17 +24,43 @@ interface SubagentCapabilities { } ``` -## The one-shot start request +```ts type-equiv +/** One managed child execution and its result delivery policy. */ +interface SubagentActivationSpec { + /** Registered backend to use. */ + readonly provider: string + /** Short task label retained in the parent catalog. */ + readonly label: string + /** Optional reserved identity for a local child; external backends allocate their own ids. */ + readonly childId?: SessionId + /** Task, parent, and backend-supported execution options. */ + readonly request: Omit + /** Cancellation before publication; callers own later cancellation through dispose. */ + readonly signal: AbortSignal + /** Parent-model notification or a program-owned result. */ + readonly delivery: 'parent' | 'caller' +} +``` -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 Agent provider, model, reasoning-effort, and token overrides, output schema, depth, tool filter, and persona require matching capability flags. In-process backends merge `agentOptions` over the parent Agent's options, scope filters and personas to child creation, and implement the supported object-rooted schema with a forced capture tool. The DSH SDK backend merges the four Agent route fields over its instance defaults and validates them in the child runtime's initialization; ACP, Codex, and Claude Code reject `agentOptions` before starting their transports. +```ts type-equiv +/** A managed execution; disposal addresses this exact activation, never a later resume. */ +interface SubagentActivation { + /** Stable identity of the child. */ + readonly childId: SessionId + /** Accepted inbox message, when the backend has a local inbox. */ + readonly messageId?: MessageId + /** Execution result, independently of eventual resource release. */ + readonly result: Promise + /** Stop and release this activation and its owned descendants. */ + dispose(): Promise +} +``` ```ts type-equiv /** - * 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 - * and resolves the durable descriptor before dispatching to - * {@link SubagentProvider.start}. + * Task and optional capabilities supplied to a backend. startActivation carries + * these fields in request while owning its label, cancellation, and delivery + * policy separately. Providers receive a resolved descriptor before start. */ interface SubagentStartRequest { /** Optional short display label persisted with a session-backed child. */ @@ -106,13 +121,9 @@ 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. - -The caller-facing request does not carry catalog format details or continuation state. `SubagentRuntime.start()` resolves the detached one-shot descriptor after capability checks, then passes this provider-facing request to the selected transport; a continuable child never reaches `SubagentProvider.start()`: - ```ts type-equiv /** - * Provider-facing one-shot request after {@link SubagentRuntime.start} resolves + * Provider-facing execution request after {@link SubagentRuntime.startActivation} resolves * the durable child descriptor. */ interface ResolvedSubagentStartRequest extends SubagentStartRequest { @@ -121,106 +132,9 @@ interface ResolvedSubagentStartRequest extends SubagentStartRequest { } ``` -## Continuable children and activations +## Local children and activations -A **continuable background subagent** is one durable child Session with at most one process-local **Activation**, the period when a reconstructed child Agent is resident. An Activation is not a request, result, cancellation, or Task: 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 - -> optional live Activation - -> one retained AgentHandle - -> Agent inbox as the only turn FIFO - -> zero or more owned child Activations -``` - -`SubagentRuntime.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. - -`SubagentRuntime.sendMessage()` is the sole model-authored message operation. It accepts the exact live sender plus a target id, permits only a direct parent or direct continuable child, derives sender attribution itself, and routes a direct-child target by Activation residency: - -| Target Activation state | `sendMessage` | -|---|---| -| `running` | steer the nearest step in the same Activation | -| `waiting` | wake and steer the same Activation | -| no Activation | cold-resume a new Activation, then steer it | - -`running` means the Agent has an active driver or maintenance task; `waiting` means no Agent activity is active but its Inbox is nonempty or it owns at least one child Activation that has not completed disposal; `settled` means no Agent activity is active, the Inbox is empty, and every owned child is disposed, at which point the manager disposes the [`AgentHandle`](core.md#creation-and-ownership) and removes the Activation. The manager derives these internal conditions from `Agent.whenIdle()`, `Agent.inbox.hasPending`, the owned-child set, and an Activation generation that invalidates stale observations, rather than maintaining a second execution state machine. After the final Session flush, the child-lock decision uses the synchronous task entry of `Agent.runMaintenance()` to claim the idle phase and close admission in the same JavaScript turn. This conservative rule does not distinguish delivery modes: context parked by `Agent.inject()` can keep an idle Activation and its live ancestors resident until a waking delivery claims it, a queue mutation removes it, or manager teardown discards it. - -The Agent inbox is the only queue. Every Agent message uses `Agent.steer()`: an idle target starts a turn, while a running target claims it at the nearest step boundary. The browser `subagent.prompt` Remote separately carries `delivery: 'queue' | 'steer'` through the same internal admission path; Queue opens a later FIFO turn, while Steer retains the Agent loop's best-effort nearest-step behavior and the message's human source. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/inserted`, `agent/inbox/claimed`, and `agent/inbox/discarded` events remain the message-lifecycle observations, and the continuation layer defines no second queue. - -Authority comes from the exact live sender. Parent-to-child delivery requires the target's `SessionHeader.parentSession` to name the sender; child-to-parent delivery requires the sender's resident Activation to name the target. Siblings, ancestors beyond one edge, self-targets, stale Agent objects, and one-shot children are rejected. Each accepted message is framed as `Agent sent a message:` and records `AgentMessageSource`; the source records the sender but grants no authority. - -For `startContinuable()`, `sendMessage()`, and browser prompt delivery, 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. The public subagent service exposes no caller-selected Agent-message scheduling; browser human Queue and Steer remain internal adapter choices. - -Live queue occurrence mutation remains in the Session domain. `session.updateQueue` admits ordinary Edit, Remove, and QueueDock Steer for a live subagent-owned Agent only when its current projected identity is continuable and its descriptor sequence is in that child's own non-seed suffix. The identity projection folds descriptors last-wins so a child descriptor supersedes descriptors retained from fork lineage; the own-suffix sequence check prevents a seed-only ancestor identity from authorizing mutation. One-shot, missing, unknown, corrupt, or cold children remain rejected, and queue mutation never cold-resumes a child. The target Session id is the human authority for these mutations, including pending `nextStep` steering or injected context. Steer requires a queued `MessageId` and an Agent that reports running when the command begins; cancellation after admission uses the Agent's accepted waking `nextTurn` fallback. Edit rewrites content under the same `MessageId`, and both Edit and Steer complete their Inbox work synchronously, so settlement observes only the final state. `agent/inbox/claimed` and `agent/inbox/discarded` wake the watcher to re-read whether any pending occurrence remains; this lets direct Agent delivery resume parked work and lets removing the last parked occurrence settle an idle child. The [human inbox-control Agent Note](../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md) owns these semantics. - -`SubagentRuntime.interrupt(targetSessionId, authority)` is the one public stop: it authorizes synchronously, issues `Agent.cancel(cause, { keepInbox: true })` on the live target, and returns without awaiting quiescence. The Activation, its unclaimed pending inbox work, and published descendants are untouched; work already claimed into the interrupted turn is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already settled — and a manager-less composition are accepted no-ops. For a live target, a mismatched parent address or caller outside its live ancestry rejects with `UNAUTHORIZED`; stale ancestor objects and self-targeting ancestor requests reject before target lookup. - -```ts type-equiv -/** - * Authority under which one interrupt request is admitted. `user` carries the - * durable direct-parent address a human client presented; `ancestor` carries - * the exact live Agent object whose recorded lineage must contain the caller. - */ -type SubagentInterruptAuthority = - | { readonly kind: 'user'; readonly parentSessionId: SessionId } - | { readonly kind: 'ancestor'; readonly agent: Agent } -``` - -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 has no active Agent work, its Inbox is empty, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal. - -Final settlement awaits `ctx.sessions.flush(session)` but ignores its participation boolean because an arbitrary listener cannot prove that a persistence backend stored the state. Rejection is logged without failing the Activation, and the manager still disposes the handle and releases ownership; 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 -/** Durable attribution for one model-authored message between adjacent Agents. */ -interface AgentMessageSource { - readonly kind: 'agent-message' - /** A message another agent addressed to this one (`relay` context form). */ - readonly form: 'relay' - /** Session id of the Agent whose tool call produced the message. */ - readonly senderSessionId: SessionId -} -``` - -```ts type-equiv -/** Options for one model-authored message between adjacent Agents. */ -interface SubagentSendMessageOptions { - /** Caller cancellation, owning the operation only until inbox acceptance. */ - readonly signal: AbortSignal -} -``` - -```ts type-equiv -/** 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 -} -``` - -When a resident Activation settles, the manager delivers one notice to the child's durable direct parent describing how that epoch ended and carrying the nonempty text blocks from its final assistant output, or `It left no closing message.` when none remain. That delivery is unconditional for every child whose id a caller received, happens before the ownership release that would let the parent be judged settled, and reaches a resident parent through the same waking Agent delivery as an Agent message. A parent whose own lineage is already tearing down receives it without a wake, because waking an idle Agent starts a turn rather than queueing work. Its source has a distinct kind so a transcript never presents a runtime account as something the child wrote. - -```ts type-equiv -/** - * Durable attribution for the runtime's own account of a continuable child - * settling. Deliberately a different kind from - * {@link AgentMessageSource}: an Agent message is content the sender chose, - * while this message is the manager stating what became of the child, and a - * transcript that merged them would credit the child with words it never wrote. - */ -interface SubagentSettledMessageSource { - readonly kind: 'subagent-settled' - /** A runtime account shown without expanding the row (`notice` context form). */ - readonly form: 'notice' - /** One-line account of how the child ended. */ - readonly summary: string - /** Session id of the child that settled. */ - readonly senderSessionId: SessionId -} -``` - -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 — 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. +A local child has a durable Session and at most one live activation. The manager reserves identity and capacity, prepares the child through its backend, and owns input admission, cold resume, and child-first disposal. A result can settle before resources are released; callers await dispose for cleanup. Structured capture closes input for that activation; a later cold resume does not inherit the output schema. ```ts type-equiv /** @@ -260,15 +174,62 @@ interface ContinuableCreateSpec { } ``` -The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) is a mode-discriminated durable identity for every session-backed subagent. Both modes carry the provider name. A `one-shot` descriptor optionally carries a caller-owned display `label`; a `continuable` descriptor requires the delegation `description` as its durable creation label and additionally snapshots resolved child `agentOptions.provider`/`model`/`reasoningEffort` and optional `persona`/`toolFilter` for cold resume. It never snapshots 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` (one run or Activation's result contract, not durable identity). +## Messages and interruption -A local one-shot provider appends the descriptor inside the child's initial turn before its first request. The continuation manager appends the descriptor after any provider-supplied lineage and before the initial prompt is admitted; `Session.inheritedEventCount` remains the fork-lineage boundary: resume-time descriptor authority reads the child's own suffix, while the list-serving identity projection folds `subagent/descriptor` last-wins so the child's own descriptor overrides a fork-seeded ancestor's. A seeded cold list skips a cache hint until an authoritative observation supplies that exact cut. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. Malformed current-version descriptors are corrupt; unsupported versions cannot be classified by this runtime. +sendMessage authorizes the exact live sender and permits only adjacent local agents. Running children receive steering input; absent continuable children resume from persistence. External executions reject follow-ups. Local children report their content through send_message; their parent receives an automatic status-only settlement notice. External executions automatically report their final text, structured result, diagnostic, and status. caller delivery suppresses these notices. drainDescendants and drainChildren await owned work teardown for both drivers. -## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries +```ts type-equiv +/** Durable attribution for one model-authored message between adjacent Agents. */ +interface AgentMessageSource { + readonly kind: 'agent-message' + /** A message another agent addressed to this one (`relay` context form). */ + readonly form: 'relay' + /** Session id of the Agent whose tool call produced the message. */ + readonly senderSessionId: SessionId +} +``` -`SubagentRuntime.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions` and the session-query engine's `listSessions()` — no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `query.observeSession()` cold observation folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished (`unsupported` remains in the type but is never produced); a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` and `ctx.agents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and refines status through the live Agent registry into its own `running`/`idle`/`ready` vocabulary, whose `ready` names a storage-only child as resumable rather than terminal. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. [The archived list-identity-projection record](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md) documents the original read-path decision. +```ts type-equiv +/** + * Durable attribution for the runtime's own account of a continuable child + * settling. Deliberately a different kind from + * {@link AgentMessageSource}: an Agent message is content the sender chose, + * while this message is the manager stating what became of the child, and a + * transcript that merged them would credit the child with words it never wrote. + */ +interface SubagentSettledMessageSource { + readonly kind: 'subagent-settled' + /** A runtime account shown without expanding the row (`notice` context form). */ + readonly form: 'notice' + /** One-line account of how the child ended. */ + readonly summary: string + /** Session id of the child that settled. */ + readonly senderSessionId: SessionId +} +``` -`SubagentRuntime.listDescendants(rootSessionId)` applies the same live-preferred corpus and projection-backed interpretation to the root's complete descendant tree in stable pre-order. Ordinary sessions and one-shot children remain traversal nodes, so continuable descendants below them are discovered; only `origin: 'subagent'` candidates produce rows. Each returned child or diagnostic adds its position from the enumerated durable header, while a cold inspection revalidates that complete lifecycle before serving identity: +```ts type-equiv +/** Options for one model-authored message between adjacent Agents. */ +interface SubagentSendMessageOptions { + /** Caller cancellation, owning the operation only until inbox acceptance. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** + * Authority under which one interrupt request is admitted. `user` carries the + * durable direct-parent address a human client presented; `ancestor` carries + * the exact live Agent object whose recorded lineage must contain the caller. + */ +type SubagentInterruptAuthority = + | { readonly kind: 'user'; readonly parentSessionId: SessionId } + | { readonly kind: 'ancestor'; readonly agent: Agent } +``` + +## Durable enumeration + +listChildren and listDescendants combine child Sessions with external execution records from parent logs. External rows carry external: true and cannot open a child Session or accept follow-ups. Their running state comes from the live activation registry; a persisted start alone does not prove that a process is alive. The subagentExternal projection retains the complete JSON result independently of parent notices. list_agents exposes external rows as running or finished with continuable: false, and local rows as running, idle, or ready. ```ts type-equiv /** @@ -284,10 +245,9 @@ type SubagentDescendantListEntry = SubagentListEntry & { } ``` +## Results and backend handles -## The terminal result: `SubagentResult` - -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 provider may attach a safe, non-assistant `diagnostic` to a non-`completed` result; the provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads and limits the complete value to 4096 UTF-8 bytes before consumers present it separately from `output`. 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. +SubagentResult is available to program consumers. The external driver retains the backend handle until disposal completes. A cleanup failure does not overwrite an already returned result; the parent log retains that output and records the final failure status. Backend-owned diagnostics must satisfy the safe-detail requirements below. ```ts type-equiv /** @@ -322,8 +282,6 @@ interface SubagentResult { } ``` -`SubagentStopReason` is a [merge-extensible derived union](core.md#the-map--derived-union-pattern) — a backend may add variants, so consumers branch on the known cases and treat an unknown terminal reason as a failure: - ```ts type-equiv /** * Why a subagent run ended. Merge-extensible (a backend may add variants); @@ -345,19 +303,13 @@ interface SubagentStopReasonMap { } ``` -## A one-shot run: `SubagentRun` - -`SubagentRun` is the consumer-owned handle for a published one-shot child — one disposable foreground delegation with one result, never a durable child handle. Prompt submission, turn work, and infrastructure faults after publication belong to `result`. Consumers await that 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 /** - * ONE-SHOT child handle returned after publication. Prompt submission, turn - * work, and infrastructure faults after that boundary belong to {@link result}. - * Consumers await that 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. + * Backend execution handle owned by the external activation driver. + * A result may become available before resource release; the manager always + * disposes the handle and awaits cleanup. Backend startup failures clean up + * partial resources before rejecting, while accepted execution failures settle + * through result. */ interface SubagentRun { /** @@ -388,11 +340,9 @@ interface SubagentRun { } ``` -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`, record `request.parent.session.id` in the child's `parentSession` header, and append the resolved descriptor inside the child's initial turn before its first request. 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`; without a local child Session, it is absent from durable enumeration. +## The provider contract: SubagentProvider -## The provider contract: `SubagentProvider` - -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. A provider whose one-shot route has static provider-owned defaults publishes optional immutable `agentRouteDefaults`, allowing a Consumer to merge model/tool overrides against the correct baseline before preflight. +Spawn and Fork contribute detached creation inputs to the local driver. Codex, Claude Code, ACP, and DSH SDK contribute one execution handle to the external driver; they do not gain multiple turns. Both paths share admission, capacity, parent ownership, cancellation, and disposal. The [package reference](../../packages/subagent/subagent/README.md) describes composition and deployment requirements. ```ts type-equiv /** @@ -415,14 +365,14 @@ interface SubagentProvider { */ readonly inheritsParentContext: boolean /** - * Optional static provider-owned provider/model route for one-shot Agent + * Optional static provider-owned provider/model route for child Agent * options. Consumers merge tool/model overrides over these values before * preflight; providers whose route derives from the parent omit it. The value * is detached immutable data and requires `agentOptions` support. */ readonly agentRouteDefaults?: Readonly<{ provider: string; model: string }> /** - * Establish a ONE-SHOT child and return its handle after publication. + * Establish one external execution and return its owned handle. * The service has already validated that every requested start-time * capability is supported and resolved `request.descriptor`, so a * session-backed implementation appends that descriptor inside the child's @@ -432,14 +382,13 @@ interface SubagentProvider { * the returned run. Distinct starts may overlap; cancellation, failure, * result settlement, and disposal remain independent for each run. */ - start(request: ResolvedSubagentStartRequest): Promise + start?(request: ResolvedSubagentStartRequest): Promise /** * OPTIONAL (continuable-creation capability): contribute the detached * creation inputs that distinguish this provider's continuable children — * 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. + * presence selects local activation execution. Providers without it execute + * through start and do not accept subsequent messages. * * This is the provider's ONLY participation in a continuable child. The * continuation manager owns identity reservation, composition, Agent @@ -452,17 +401,6 @@ interface SubagentProvider { } ``` -Provider `start()` fulfills with a published 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; a `start()` rejection implies cleanup of unpublished resources and emits no lifecycle pair, while a post-publication result rejection closes the emitted 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 names the provider that started the run or Activation epoch; it does not claim that the provider remains registered when the edge is emitted. - -## In-process backends: permission, depth, and seed - -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. Permission, depth, and fork seeding reuse existing Session vocabulary: - -- **Delegated permission** is captured before the first await. Auto and Full access parents append their captured `permission/preset` identity to the fresh child after fork seeding and sandbox/approval overrides. One-shot and continuable children share this path; cold resume reads only the child log. Read Only and Workspace Write retain the inherited sandbox override plus `approval: never`, so unmatched bundles remain `custom`. Each Auto child call is reviewed independently using existing `parentSession`, creation prompt, and authenticated human/direct-parent messages. The [Auto review decision](../../.agents/notes/implemented/feature/2026-08-28-auto-review.md) defines low/medium/high semantics; no delegation records, receipt, Header field, descriptor field, or Session format is added. - -- **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`](core.md#creation-and-ownership) (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/runtime-diagnostics/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). - @@ -491,7 +429,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../. ### `ctx.subagents` — `SubagentRuntime` -Named provider registry with one-shot runs, durable discovery, and continuable-child operations. +Named provider registry with managed activations, durable discovery, and local child messaging. ```ts cordis-catalog /** @@ -502,15 +440,18 @@ Named provider registry with one-shot runs, durable discovery, and continuable-c resolveMaxDepth(configured?: number | 'provider-managed'): number | undefined /** - * 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. + * Establish a managed child with backend-specific execution capabilities. + * @param spec - task, backend, and result recipient. + * @returns identities, execution result, and exact-activation disposal. */ -async startContinuable(spec: ContinuableStartSpec): Promise +async startActivation(spec: SubagentActivationSpec): Promise + +/** + * Wait for this live parent's currently owned child work without cancelling it. + * @param parent - the exact parent whose descendants should settle. + * @returns whether any work was observed; hosts recheck parent idle after true. + */ +async waitForChildren(parent: Agent): Promise /** * Steer one model-authored message to the sender's direct parent or direct @@ -529,13 +470,13 @@ async startContinuable(spec: ContinuableStartSpec): Promise async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions, ): Promise /** - * Interrupt one live continuable child's current turn under a human parent + * Interrupt one live child's current execution under a human parent * address or an exact live ancestor Agent. Fire-and-return: the cancel * signal is issued before this returns, but the target may keep running * until it observes the signal. Unclaimed pending inbox work, the Activation, * and published descendants are preserved; claimed work is not requeued. * Once the interrupted driver is idle, a waking send resumes the parked FIFO - * queue. An absent target — including a one-shot or unknown id — + * queue. External backends stop their single execution. An absent target * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. * @param targetSessionId - the durable child session id to interrupt. @@ -546,31 +487,31 @@ async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void /** - * Close continuable admission below exact live parent Agents, stop only their + * Close subagent 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`. + * @returns once every retained descendant activation released its driver. * @throws an aggregate error after all branches settle when any failed. */ -async drainContinuableDescendants(parents: readonly Agent[]): Promise +async drainDescendants(parents: readonly Agent[]): Promise /** - * Release selected resident continuable direct children of one exact live + * Release selected resident direct children of one exact live * parent. Other children of the same parent remain admitted and resident. * Absent targets and a manager-less composition are accepted no-ops. * @param parent - exact live direct parent authorizing the selected release. * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected Activation released its `AgentHandle`. + * @returns once every selected activation released its driver. * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a * different parent or the supplied parent identity is stale. */ -async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise +async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise /** - * Enumerate the parent's direct session-backed subagents without loading or + * Enumerate the parent's direct local and external subagents without loading or * resuming an Agent. The Session query service supplies one live-preferred * corpus and shared point observations; the projection cache supplies * immutable descriptor hits without opening cold logs. The registered @@ -586,7 +527,7 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P * @throws {@link SubagentError} when the projection registry or the session * store is not mounted, or the caller cancels the listing. */ -listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise +async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise /** * Enumerate the root's complete session-backed subagent tree in stable @@ -603,7 +544,7 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise +async listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise /** * Remote face of {@link listChildren} for one browser: the durable listing @@ -677,20 +618,6 @@ getProvider(name: string): SubagentProvider | undefined * @returns the registered names. */ list(): string[] - -/** - * Establish a published child on the named provider. Capability and semantic - * checks run before delegation. Provider ownership lasts until its promise - * fulfills; a rejection therefore has no run for the caller to dispose and - * emits no run lifecycle events. Post-publication turn and infrastructure - * failures settle through the returned run. - * A catalog append failure disposes the run and handles its result rejection; - * the caller receives the catalog error even if disposal also fails. - * @param name - the provider to use. - * @param request - child label, prompt, parent, signal, and optional capabilities. - * @returns the published holder-owned run. - */ -async start(name: string, request: SubagentStartRequest): Promise ``` Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 0fbde92836..799aa100ae 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -1,30 +1,19 @@ -# Subagent +# 子代理 [English](subagent.md) | 中文 -subagent seam 让一个 agent(智能体)将工作委派给子 agent。与 [bash](shell.zh.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环),因此其类型定义在此而非 [core.md](core.zh.md) 中。它不同于其他能力 seam,因为**同一上下文中可共存多个提供方实现**,并按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。该注册表遵循 [LLM(大语言模型)适配器注册表](llm-streaming.zh.md),而非单服务的 bash 执行器。 +统一的 activation API 管理本地会话和外部执行。 -Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。Service Provider 是六个兄弟包:`dsh-subagent-spawn-in-process`、`dsh-subagent-fork-in-process`、`dsh-subagent-acp`、`dsh-subagent-codex`、`dsh-subagent-claude-code`、`dsh-subagent-dsh-sdk`;面向模型的 Consumer 包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并直接基于会话存储和可选的会话持久化提供只读的 child 与后代发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md)和[相邻 Agent 消息 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md);[已归档的列表身份投影记录](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md)记录了最初的列表身份决策。 +## 能力与 activation 请求 -源码:[`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) - -`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。 - -## 两类能力,两种发现方式 - -提供方通过一个静态描述符公布其**启动时**功能,服务会在单次 run 存在之前即行检查;如果请求依赖提供方不具备的功能,会被明确拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。这些 flag 仅描述单次 [`start()`](#the-provider-contract-subagentprovider) 路径,即由提供方组合子 agent 的路径。**可继续**子 agent 由继续执行管理器自行组合,因此它们由唯一一个可选方法把关,方法存在即为能力,并以 TypeScript 的类型收窄作为发现机制:[`SubagentProvider.prepareContinuable`](#the-provider-contract-subagentprovider)。 +所有后端都使用 startActivation。能力标记校验请求选项;prepareContinuable 选择本地 driver,start 选择一次外部执行。面向模型的工具采用 parent 回传。Workflow 与 code mode 消费者可以等待 caller 结果,不额外向父代理注入消息。 ```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 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. + * Start-time options supported by a registered backend. The manager checks + * every requested option before preparing a local child or starting an external + * execution. depthLimit controls maxDepth; the remaining flag names match + * SubagentStartRequest fields. */ interface SubagentCapabilities { readonly agentOptions: boolean @@ -35,17 +24,43 @@ interface SubagentCapabilities { } ``` -## 单次启动请求 +```ts type-equiv +/** One managed child execution and its result delivery policy. */ +interface SubagentActivationSpec { + /** Registered backend to use. */ + readonly provider: string + /** Short task label retained in the parent catalog. */ + readonly label: string + /** Optional reserved identity for a local child; external backends allocate their own ids. */ + readonly childId?: SessionId + /** Task, parent, and backend-supported execution options. */ + readonly request: Omit + /** Cancellation before publication; callers own later cancellation through dispose. */ + readonly signal: AbortSignal + /** Parent-model notification or a program-owned result. */ + readonly delivery: 'parent' | 'caller' +} +``` -工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 Agent 提供方、模型、推理强度与 token 覆盖、output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。进程内后端会把 `agentOptions` 合并到父 Agent 选项之上,将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。DSH SDK 后端会把四个 Agent 路由字段合并到实例默认值之上,并在子运行时初始化期间校验;ACP、Codex 与 Claude Code 会在启动传输前拒绝 `agentOptions`。 +```ts type-equiv +/** A managed execution; disposal addresses this exact activation, never a later resume. */ +interface SubagentActivation { + /** Stable identity of the child. */ + readonly childId: SessionId + /** Accepted inbox message, when the backend has a local inbox. */ + readonly messageId?: MessageId + /** Execution result, independently of eventual resource release. */ + readonly result: Promise + /** Stop and release this activation and its owned descendants. */ + dispose(): Promise +} +``` ```ts type-equiv /** - * 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 - * and resolves the durable descriptor before dispatching to - * {@link SubagentProvider.start}. + * Task and optional capabilities supplied to a backend. startActivation carries + * these fields in request while owning its label, cancellation, and delivery + * policy separately. Providers receive a resolved descriptor before start. */ interface SubagentStartRequest { /** Optional short display label persisted with a session-backed child. */ @@ -106,13 +121,9 @@ interface SubagentStartRequest { } ``` -`signal` 是就绪前后唯一的取消通道。[subagent 组合控制 Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md)规定 persona、live 全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 - -面向调用方的请求不携带目录格式细节或继续执行状态。`SubagentRuntime.start()` 会在能力检查后解析分离的一次性描述符,再将以下面向提供方的请求传给所选传输;可继续子 agent 绝不会到达 `SubagentProvider.start()`: - ```ts type-equiv /** - * Provider-facing one-shot request after {@link SubagentRuntime.start} resolves + * Provider-facing execution request after {@link SubagentRuntime.startActivation} resolves * the durable child descriptor. */ interface ResolvedSubagentStartRequest extends SubagentStartRequest { @@ -121,106 +132,9 @@ interface ResolvedSubagentStartRequest extends SubagentStartRequest { } ``` -## 可继续子 agent 与激活 +## 本地子代理与 activation -**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**,即被重建的子 Agent 处于驻留状态的时段。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 -``` - -`SubagentRuntime.startContinuable()` 会预留稳定的子 agent id,对版本化的 `subagent/descriptor` payload 建立快照,向指定提供方索取其分离的 `ContinuableCreateSpec`,通过私有的 activation-owner 作用域创建子 Agent,建立任何可继续父级的所有权,并提交初始提示词。当收件箱(inbox)准入产出消息 id 时,它以 `{ childId, messageId }` resolve——无需等待轮次开始,也无需等待消息进入会话日志。在该准入之前的任何失败都会以两个 id 都不返回的方式 reject,并 dispose(资源释放)任何已创建的 handle,回滚 Activation 与父级所有权。 - -`SubagentRuntime.sendMessage()` 是唯一由模型编写消息的操作。它接收确切在线 sender 与目标 id,只允许直接 parent 或直接可继续 child,自行推导 sender 来源信息,并根据目标 child 的 Activation 驻留状态路由: - -| 目标 Activation 状态 | `sendMessage` | -|---|---| -| `running` | 在同一 Activation 中 steer 最近的 step | -| `waiting` | 唤醒并 steer 同一 Activation | -| 无 Activation | 冷恢复新的 Activation,然后 steer | - -`running` 表示 Agent 拥有活跃的 driver 或 maintenance 任务;`waiting` 表示没有活跃的 Agent 工作,但其 Inbox 非空或仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示没有活跃的 Agent 工作、Inbox 为空且其拥有的每个子级都已 dispose,此时管理器会 dispose [`AgentHandle`](core.zh.md#creation-and-ownership) 并移除该 Activation。管理器根据 `Agent.whenIdle()`、`Agent.inbox.hasPending`、其拥有的子级集合,以及让过期观察失效的 Activation generation 推导这些内部条件,而非维护第二套执行状态机。最终 Session flush 之后,child-lock 决策会通过 `Agent.runMaintenance()` 的同步 task 入口占用 idle 阶段,并在同一个 JavaScript turn 内关闭准入。这条保守规则不区分投递模式:`Agent.inject()` 停放的 context 可以让空闲 Activation 及其在线祖先继续驻留,直到唤醒投递将其 claim、queue 变更将其移除,或 manager teardown 将其丢弃。 - -Agent 收件箱是唯一队列。每条 Agent 消息都使用 `Agent.steer()`:空闲目标会启动一个轮次,运行中目标则在最近的 step 边界领取消息。浏览器 `subagent.prompt` Remote 会另行通过同一条内部准入路径携带 `delivery: 'queue' | 'steer'`;Queue 开启后续 FIFO 轮次,Steer 保留 Agent loop 的 best-effort 最近 step 行为以及消息的人类来源。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/inserted`、`agent/inbox/claimed` 与 `agent/inbox/discarded` 事件仍是消息生命周期的观测点,继续执行层不定义第二条队列。 - -权限来自确切在线 sender。parent 到 child 的投递要求目标的 `SessionHeader.parentSession` 指向 sender;child 到 parent 的投递要求 sender 的驻留 Activation 指向目标。sibling、相隔多于一条边的 ancestor、self-target、陈旧 Agent 对象与一次性 child 都会被拒绝。每条已接受消息都以 `Agent sent a message:` 作为前缀,并记录 `AgentMessageSource`;来源信息记录 sender,但不授予权限。 - -对于 `startContinuable()`、`sendMessage()` 与浏览器 prompt 投递,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent。公开 subagent 服务不暴露由调用方选择的 Agent 消息调度;浏览器人类 Queue 与 Steer 仍是内部适配器选择。 - -在线 queue occurrence 变更属于 Session 域。只有在线 subagent-owned Agent 的当前 projection identity 为 continuable,且其 descriptor 序号位于该 child 自身的非 seed suffix 时,`session.updateQueue` 才会接纳普通 Edit、Remove 与 QueueDock Steer。Identity projection 以 last-wins 方式折叠 descriptor,因此 child descriptor 会覆盖 fork lineage 保留的 descriptor;own-suffix 序号检查会阻止仅来自 seed 的祖先 identity 授权变更。One-shot、缺失、未知、损坏或冷 child 会被拒绝,queue 变更绝不会冷恢复 child。这些变更以目标 Session id 作为人类权限,包括待处理 `nextStep` steering 或注入 context。Steer 要求 queued `MessageId`,且 command 开始时 Agent 必须报告 running;准入后发生取消时,会使用 Agent 已接受的唤醒 `nextTurn` fallback。Edit 会在同一个 `MessageId` 下改写内容,且 Edit 与 Steer 都会同步完成 Inbox 变更,因此 settlement 只会观察最终状态。`agent/inbox/claimed` 与 `agent/inbox/discarded` 都会唤醒 watcher 重新读取是否仍有待处理 occurrence;这样,直接 Agent 投递可以恢复停放工作,而移除最后一个停放 occurrence 可使 idle child 结算。[人类 inbox 控制 Agent Note](../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md)拥有这些语义。 - -`SubagentRuntime.interrupt(targetSessionId, authority)` 是唯一的公开停止操作:它同步完成鉴权,对在线目标发出 `Agent.cancel(cause, { keepInbox: true })`,然后不等待完全停稳即返回。Activation、其尚未领取的待处理 inbox 工作与已发布的后代均不受影响;已被领取进入中断轮次的工作不会重新入队。被中断的 driver 进入 idle 后,一次唤醒发送会恢复被暂停的 FIFO 队列。不存在的目标——未知、一次性或已结算——以及未绑定管理器的组合是被接受的 no-op。对在线目标,错误的 parent 地址或不在其在线祖先链中的调用方会以 `UNAUTHORIZED` 拒绝;陈旧的 ancestor 对象和指向自身的 ancestor 请求会在查找目标前拒绝。 - -```ts type-equiv -/** - * Authority under which one interrupt request is admitted. `user` carries the - * durable direct-parent address a human client presented; `ancestor` carries - * the exact live Agent object whose recorded lineage must contain the caller. - */ -type SubagentInterruptAuthority = - | { readonly kind: 'user'; readonly parentSessionId: SessionId } - | { readonly kind: 'ancestor'; readonly agent: Agent } -``` - -每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 没有活跃工作、其 Inbox 为空、该子 agent 的每个子级都已 dispose、best-effort 的最终会话 flush 结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。 - -最终结算会等待 `ctx.sessions.flush(session)`,但会忽略其参与布尔值,因为任意 listener 都无法证明某个持久化后端已存储该状态。rejection 会被记录,但不会使 Activation 失败;管理器仍会 dispose 该 handle 并释放所有权,此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。管理器卸载会调用内部的管理器全局 drain,关闭准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。 - -```ts type-equiv -/** Durable attribution for one model-authored message between adjacent Agents. */ -interface AgentMessageSource { - readonly kind: 'agent-message' - /** A message another agent addressed to this one (`relay` context form). */ - readonly form: 'relay' - /** Session id of the Agent whose tool call produced the message. */ - readonly senderSessionId: SessionId -} -``` - -```ts type-equiv -/** Options for one model-authored message between adjacent Agents. */ -interface SubagentSendMessageOptions { - /** Caller cancellation, owning the operation only until inbox acceptance. */ - readonly signal: AbortSignal -} -``` - -```ts type-equiv -/** 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 -} -``` - -当驻留 Activation 结算时,管理器会向该 child 持久化的直接 parent 投递一条通知,说明该 epoch 如何结束,并携带其最终 assistant 输出中的非空文本块;若没有剩余的非空文本,则携带 `It left no closing message.`。对每个调用方拿到过 id 的 child,这条投递都是无条件的;它发生在会让 parent 被判定为已结算的所有权释放之前,并通过与 Agent 消息相同的唤醒 Agent 投递到达驻留 parent。若 parent 自身所在的谱系已在拆卸中,这条通知会以不唤醒的方式送达,因为唤醒一个 idle Agent 是开启一个轮次,而不是排队等待工作。其来源信息使用一个独立的 kind,因此 transcript(文本记录)绝不会把运行时的记账呈现为 child 自己写下的内容。 - -```ts type-equiv -/** - * Durable attribution for the runtime's own account of a continuable child - * settling. Deliberately a different kind from - * {@link AgentMessageSource}: an Agent message is content the sender chose, - * while this message is the manager stating what became of the child, and a - * transcript that merged them would credit the child with words it never wrote. - */ -interface SubagentSettledMessageSource { - readonly kind: 'subagent-settled' - /** A runtime account shown without expanding the row (`notice` context form). */ - readonly form: 'notice' - /** One-line account of how the child ended. */ - readonly summary: string - /** Session id of the child that settled. */ - readonly senderSessionId: SessionId -} -``` - -提供方只参与准备初始创建 spec,`spawn` 与 `fork` 在此有所不同。其返回的 spec 只携带分离的、提供方专属的创建输入——即可选的父级历史种子——不含 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作。冷恢复根本不经由提供方分发:管理器折叠通用描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,并提交等待中的轮次。 +本地子代理拥有持久 Session,最多有一个在线 activation。Manager 预留身份和容量,通过后端准备子代理,并管理输入准入、冷恢复以及先子后父的释放顺序。结果可以先于资源释放就绪;调用方等待 dispose 完成清理。结构化结果提交会关闭该 activation 的输入;之后的冷恢复不会继承输出 schema。 ```ts type-equiv /** @@ -260,15 +174,62 @@ interface ContinuableCreateSpec { } ``` -描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)是每个由会话支撑的 subagent 所使用、按模式判别的持久化身份。两种模式都携带提供方名称。`one-shot` 描述符可以携带调用方拥有的可选显示 `label`;`continuable` 描述符要求以委派 `description` 作为持久化创建标签,并另外对已解析的子 agent `agentOptions.provider`/`model`/`reasoningEffort` 与可选的 `persona`/`toolFilter` 建立快照,用于冷恢复。它绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。描述符省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次运行或 Activation 的结果约定,而非持久化身份)。 +## 消息与中断 -本地一次性提供方会在子 agent 的初始轮次内、首次请求前追加描述符。继续执行管理器会在任何提供方提供的谱系之后、初始提示词获准之前追加描述符;`Session.inheritedEventCount` 仍是 fork 谱系边界:恢复时的描述符权威读取子 agent 自身的后缀,而供列表使用的身份投影以 last-wins 折叠 `subagent/descriptor`,子 agent 自己的描述符会覆盖 fork seed 中祖先的描述符。seeded cold list 会跳过 cache hint,直到权威 observation 提供该精确 cut。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本描述符属于损坏;本运行时无法对不受支持的版本进行分类。 +sendMessage 根据确切的在线发送者实例授权,仅允许相邻本地代理通信。运行中的子代理接收引导输入;不在线的可继续子代理从持久化恢复。外部执行拒绝后续消息。本地子代理通过 send_message 汇报内容;其父代理收到的自动结束通知仅包含状态。外部执行自动回传最终文本、结构化结果、诊断和状态。caller 回传会抑制这些通知。drainDescendants 与 drainChildren 对两种 driver 都等待所属任务清理完成。 -## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目 +```ts type-equiv +/** Durable attribution for one model-authored message between adjacent Agents. */ +interface AgentMessageSource { + readonly kind: 'agent-message' + /** A message another agent addressed to this one (`relay` context form). */ + readonly form: 'relay' + /** Session id of the Agent whose tool call produced the message. */ + readonly senderSessionId: SessionId +} +``` -`SubagentRuntime.listChildren(parentSessionId)` 从 `ctx.sessions` 与会话查询引擎 `listSessions()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 约定负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `query.observeSession()` 冷观察上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分(`unsupported` 仍保留在类型中但从不产出);运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时要求 `ctx.subagents` 与 `ctx.agents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并通过在线 Agent 注册表将状态细化为自己的 `running`/`idle`/`ready` 词汇,其中 `ready` 把仅存于存储的 child 命名为可恢复而非终态。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。[已归档的列表身份投影记录](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md)记录了最初的读路径决策。 +```ts type-equiv +/** + * Durable attribution for the runtime's own account of a continuable child + * settling. Deliberately a different kind from + * {@link AgentMessageSource}: an Agent message is content the sender chose, + * while this message is the manager stating what became of the child, and a + * transcript that merged them would credit the child with words it never wrote. + */ +interface SubagentSettledMessageSource { + readonly kind: 'subagent-settled' + /** A runtime account shown without expanding the row (`notice` context form). */ + readonly form: 'notice' + /** One-line account of how the child ended. */ + readonly summary: string + /** Session id of the child that settled. */ + readonly senderSessionId: SessionId +} +``` -`SubagentRuntime.listDescendants(rootSessionId)` 将同一份实时优先语料与基于投影的解释应用到根的完整后代树,并按稳定 pre-order 输出。普通会话和一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现;只有 `origin: 'subagent'` 的候选会生成条目。每个返回的 child 或 diagnostic 都从枚举所得的持久 header 附加树位置;冷检查在提供身份前还会重新校验完整生命周期: +```ts type-equiv +/** Options for one model-authored message between adjacent Agents. */ +interface SubagentSendMessageOptions { + /** Caller cancellation, owning the operation only until inbox acceptance. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** + * Authority under which one interrupt request is admitted. `user` carries the + * durable direct-parent address a human client presented; `ancestor` carries + * the exact live Agent object whose recorded lineage must contain the caller. + */ +type SubagentInterruptAuthority = + | { readonly kind: 'user'; readonly parentSessionId: SessionId } + | { readonly kind: 'ancestor'; readonly agent: Agent } +``` + +## 持久枚举 + +listChildren 与 listDescendants 合并子 Session 和父日志中的外部执行记录。外部条目带有 external: true,不能打开子 Session 或接受后续消息。其运行状态来自在线 activation 注册表;仅有持久启动记录不代表进程仍然存活。subagentExternal 投影独立于父通知保留完整 JSON 结果。list_agents 将外部条目标为 running 或 finished,并带有 continuable: false;本地条目标为 running、idle 或 ready。 ```ts type-equiv /** @@ -284,12 +245,9 @@ type SubagentDescendantListEntry = SubagentListEntry & { } ``` +## 结果与后端句柄 - - -## 终态结果:`SubagentResult` - -单次 run 的最终产出,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到它,当子 agent 失败或结束时未产出有效 capture 时,提供方可能返回 `stopReason: 'error'`。提供方可以为非 `completed` 结果附带安全且不属于 assistant 内容的 `diagnostic`;在消费方将它与 `output` 分开呈现前,提供方会排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整值限制在 4096 个 UTF-8 字节以内。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整——消费方将其映射为 `isError` 的工具结果,而非将部分输出报告为成功。 +SubagentResult 供程序消费方读取。外部 driver 保留后端句柄,直到释放完成。清理失败不会覆盖已经返回的结果;父日志保留该输出并记录最终失败状态。后端提供的诊断必须满足下述安全信息要求。 ```ts type-equiv /** @@ -324,8 +282,6 @@ interface SubagentResult { } ``` -`SubagentStopReason` 是一个[可合并扩展的派生联合类型](core.zh.md#the-map--derived-union-pattern)——后端可以添加变体,因此消费方应对已知 case 分支处理,将未知的终态原因视为失败: - ```ts type-equiv /** * Why a subagent run ended. Merge-extensible (a backend may add variants); @@ -347,19 +303,13 @@ interface SubagentStopReasonMap { } ``` -## 单次 run:`SubagentRun` - -`SubagentRun` 是消费方持有的、指向一个已发布单次子 agent 的句柄——一次可 dispose 的前台委派,只有一个结果,绝不是持久化子 agent handle。发布后的提示词提交、轮次工作与基础设施故障归 `result` 所有。消费方 await 该结果并始终 dispose 该 run,直至完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有无法表示的基础设施故障才会 reject。run 没有 steering,也没有恢复:可继续对话根本没有 run,因为继续执行管理器直接持有它们的 `AgentHandle`,并通过子 agent 自己的收件箱为每个轮次排序。 - ```ts type-equiv /** - * ONE-SHOT child handle returned after publication. Prompt submission, turn - * work, and infrastructure faults after that boundary belong to {@link result}. - * Consumers await that 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. + * Backend execution handle owned by the external activation driver. + * A result may become available before resource release; the manager always + * disposes the handle and awaits cleanup. Backend startup failures clean up + * partial resources before rejecting, while accepted execution failures settle + * through result. */ interface SubagentRun { /** @@ -390,13 +340,9 @@ interface SubagentRun { } ``` -本地单次 run 必须在 `start()` fulfill 之前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切的子 agent,在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`,并在子 agent 的初始轮次内、首次请求前追加已解析的描述符。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`;由于没有本地 child Session,它不会出现在持久化枚举结果中。 +## 提供方约定:SubagentProvider - - -## 提供方约定:`SubagentProvider` - -每个提供方都是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力,并拒绝在没有 `prepareContinuable` 的提供方上发起可继续 start。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型措辞,而不暗示继承了工具、服务或权限。如果某个提供方的一次性路由拥有静态的提供方自有默认值,它会公开可选且不可变的 `agentRouteDefaults`,使 Consumer 能够在预检前以正确基线合并模型与工具覆盖。 +Spawn 与 Fork 向本地 driver 提供独立的创建输入。Codex、Claude Code、ACP 和 DSH SDK 向外部 driver 提供一个执行句柄,不增加多轮能力。两条路径共用准入、容量、父子所有权、取消和释放机制。[包参考](../../packages/subagent/subagent/README.zh.md)说明组合与部署要求。 ```ts type-equiv /** @@ -419,14 +365,14 @@ interface SubagentProvider { */ readonly inheritsParentContext: boolean /** - * Optional static provider-owned provider/model route for one-shot Agent + * Optional static provider-owned provider/model route for child Agent * options. Consumers merge tool/model overrides over these values before * preflight; providers whose route derives from the parent omit it. The value * is detached immutable data and requires `agentOptions` support. */ readonly agentRouteDefaults?: Readonly<{ provider: string; model: string }> /** - * Establish a ONE-SHOT child and return its handle after publication. + * Establish one external execution and return its owned handle. * The service has already validated that every requested start-time * capability is supported and resolved `request.descriptor`, so a * session-backed implementation appends that descriptor inside the child's @@ -436,14 +382,13 @@ interface SubagentProvider { * the returned run. Distinct starts may overlap; cancellation, failure, * result settlement, and disposal remain independent for each run. */ - start(request: ResolvedSubagentStartRequest): Promise + start?(request: ResolvedSubagentStartRequest): Promise /** * OPTIONAL (continuable-creation capability): contribute the detached * creation inputs that distinguish this provider's continuable children — * 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. + * presence selects local activation execution. Providers without it execute + * through start and do not accept subsequent messages. * * This is the provider's ONLY participation in a continuable child. The * continuation manager owns identity reservation, composition, Agent @@ -456,17 +401,6 @@ interface SubagentProvider { } ``` -提供方的 `start()` 会以已发布的 run fulfill。服务铸造唯一的 `runId`,从提供方确切的 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;`start()` rejection 意味着未发布资源已清理,且不会 emit 生命周期事件对,而发布后的结果 rejection 会结束已经 emit 的事件对。每个可继续 Activation 都会为其驻留纪元 emit 相同的仅观察事件对,因此一次冷恢复就是一段拥有自己 `runId` 的新纪元。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,且会隔离各自的 listener 异常。其中的 `provider` 字段标明了启动 run 或 Activation 时段的提供方,并不声明该 edge 发出时提供方仍处于注册状态。 - -## 进程内后端:权限、深度与种子 - -spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将取消信号传入核心创建流程,并通过 `AgentHandle` 进行 dispose;而可继续子 agent 则由继续执行管理器通过其自己的 activation-owner 作用域创建。移除提供方会阻止新的 start,但不会撤销已接受的 run。每个子 agent 获得一个新的扁平作用域,而非继承父级注册。权限、深度与 fork 种子注入复用既有的 Session 词汇: - -- **委派权限**在首次 await 前捕获。Auto 与 Full access 父级在 fresh child 完成 fork seed 和 sandbox/approval override 后,追加捕获的 `permission/preset` 身份。单次与可继续 child 共用此路径;cold resume 只读取 child 日志。Read Only 与 Workspace Write 保留继承的 sandbox override 加 `approval: never`,不匹配预设的组合仍为 `custom`。每个 Auto child 调用都使用既有 `parentSession`、创建 prompt 和经过核验的 human/直接父级消息独立审查。[Auto review 决策](../../.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md)定义 low/medium/high 语义;不增加 委派记录、receipt、Header 字段、descriptor 字段或 Session format。 - -- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 -- **Fork 种子注入**使用 [`CreateAgentOptions.seed`](core.zh.md#creation-and-ownership)(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/runtime-diagnostics/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 - @@ -495,7 +429,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../. ### `ctx.subagents` — `SubagentRuntime` -Named provider registry with one-shot runs, durable discovery, and continuable-child operations. +Named provider registry with managed activations, durable discovery, and local child messaging. ```ts cordis-catalog /** @@ -506,15 +440,18 @@ Named provider registry with one-shot runs, durable discovery, and continuable-c resolveMaxDepth(configured?: number | 'provider-managed'): number | undefined /** - * 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. + * Establish a managed child with backend-specific execution capabilities. + * @param spec - task, backend, and result recipient. + * @returns identities, execution result, and exact-activation disposal. */ -async startContinuable(spec: ContinuableStartSpec): Promise +async startActivation(spec: SubagentActivationSpec): Promise + +/** + * Wait for this live parent's currently owned child work without cancelling it. + * @param parent - the exact parent whose descendants should settle. + * @returns whether any work was observed; hosts recheck parent idle after true. + */ +async waitForChildren(parent: Agent): Promise /** * Steer one model-authored message to the sender's direct parent or direct @@ -533,13 +470,13 @@ async startContinuable(spec: ContinuableStartSpec): Promise async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions, ): Promise /** - * Interrupt one live continuable child's current turn under a human parent + * Interrupt one live child's current execution under a human parent * address or an exact live ancestor Agent. Fire-and-return: the cancel * signal is issued before this returns, but the target may keep running * until it observes the signal. Unclaimed pending inbox work, the Activation, * and published descendants are preserved; claimed work is not requeued. * Once the interrupted driver is idle, a waking send resumes the parked FIFO - * queue. An absent target — including a one-shot or unknown id — + * queue. External backends stop their single execution. An absent target * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. * @param targetSessionId - the durable child session id to interrupt. @@ -550,31 +487,31 @@ async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void /** - * Close continuable admission below exact live parent Agents, stop only their + * Close subagent 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`. + * @returns once every retained descendant activation released its driver. * @throws an aggregate error after all branches settle when any failed. */ -async drainContinuableDescendants(parents: readonly Agent[]): Promise +async drainDescendants(parents: readonly Agent[]): Promise /** - * Release selected resident continuable direct children of one exact live + * Release selected resident direct children of one exact live * parent. Other children of the same parent remain admitted and resident. * Absent targets and a manager-less composition are accepted no-ops. * @param parent - exact live direct parent authorizing the selected release. * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected Activation released its `AgentHandle`. + * @returns once every selected activation released its driver. * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a * different parent or the supplied parent identity is stale. */ -async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise +async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise /** - * Enumerate the parent's direct session-backed subagents without loading or + * Enumerate the parent's direct local and external subagents without loading or * resuming an Agent. The Session query service supplies one live-preferred * corpus and shared point observations; the projection cache supplies * immutable descriptor hits without opening cold logs. The registered @@ -590,7 +527,7 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P * @throws {@link SubagentError} when the projection registry or the session * store is not mounted, or the caller cancels the listing. */ -listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise +async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise /** * Enumerate the root's complete session-backed subagent tree in stable @@ -607,7 +544,7 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise +async listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise /** * Remote face of {@link listChildren} for one browser: the durable listing @@ -681,20 +618,6 @@ getProvider(name: string): SubagentProvider | undefined * @returns the registered names. */ list(): string[] - -/** - * Establish a published child on the named provider. Capability and semantic - * checks run before delegation. Provider ownership lasts until its promise - * fulfills; a rejection therefore has no run for the caller to dispose and - * emits no run lifecycle events. Post-publication turn and infrastructure - * failures settle through the returned run. - * A catalog append failure disposes the run and handles its result rejection; - * the caller receives the catalog error even if disposal also fails. - * @param name - the provider to use. - * @param request - child label, prompt, parent, signal, and optional capabilities. - * @returns the published holder-owned run. - */ -async start(name: string, request: SubagentStartRequest): Promise ``` Types: [Agent](core.zh.md) · [ContentBlock](llm-streaming.zh.md) · [MessageId](llm-streaming.zh.md) · [SessionId](core.zh.md) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index e9cdf89a4f..8432961c60 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.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/tool-catalog.md -tool-catalog.md: eae53047f91a2a86fd9d5e68ff2f84d8cf62faa6 -tool-catalog.zh.md: 15dda3b508e78dc3eeaddabed05b60f318886064 +tool-catalog.md: e75fa3551107a2bbbdb772070a579df4215a30ec +tool-catalog.zh.md: ed979ad69f56d6c66342714cf1cbdf16d453c85c diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index eae53047f9..e75fa35511 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -37,9 +37,9 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@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` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | -| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | -| `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance selects its backend through `provider` and reads model-selection settings through `modelSelectionSettings`. Every delegation returns a child id promptly; local children accept follow-ups, while external backends report their single execution automatically. | +| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named discovery, interruption, and local-message tools over managed subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | +| `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands and PTY sends are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | | `@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. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -1825,7 +1825,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag ### `subagent` -Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. +Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool starts an independently managed subagent and immediately returns its id. The runtime notifies you when it finishes. The child reports results with `send_message`; use `send_message` to steer it while running or continue its conversation after it finishes. ```json { @@ -1838,10 +1838,6 @@ Delegate a self-contained task to a subagent (a separate agent that works in its "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": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." } }, "required": [ @@ -1853,7 +1849,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. +The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance selects its backend through `provider` and reads model-selection settings through `modelSelectionSettings`. Every delegation returns a child id promptly; local children accept follow-ups, while external backends report their single execution automatically. @@ -1861,7 +1857,7 @@ The registered delegation name is the load-time `toolName` config (default `suba ### `interrupt_agent` -Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. +Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. For local agents, only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. External executions stop permanently and cannot receive follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. ```json { @@ -1882,7 +1878,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub ### `list_agents` -List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` steers a running child at its nearest step boundary or starts a turn for an idle or ready child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. +List your subagents by id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` steers a running child at its nearest step boundary or starts a turn for an idle or ready child, and a direct child remains a `send_message` candidate in every local status. External executions carry `continuable: false`, are running or finished, and cannot receive follow-ups. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. ```json { @@ -1928,7 +1924,7 @@ Send a message to a direct continuable child by its agent id. If you are a resid Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) -The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). +The globally named discovery, interruption, and local-message tools over managed subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). @@ -2001,7 +1997,7 @@ Read a background job. Stream jobs return only output since the previous read; f Source: [`packages/jobs/tool-jobs/src/index.ts`](../packages/jobs/tool-jobs/src/index.ts) -The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. +The kind-agnostic background-job controller: background bash commands and PTY sends are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 15dda3b508..ed979ad69f 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -41,9 +41,9 @@ | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@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` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 | -| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | -| `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `provider` 选择后端,通过 `modelSelectionSettings` 读取模型选择设置。每次委派都会立即返回子代理 id;本地子代理接受后续消息,外部后端自动汇报单次执行结果。 | +| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是用于发现、中断托管 subagent 和向本地子代理发送消息的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | +| `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令与 PTY 发送 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - | @@ -1831,7 +1831,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `subagent` -将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。 +将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此工具启动独立管理的 subagent 并立即返回其 id。运行时会在任务结束时通知你。子代理通过 `send_message` 汇报结果;你可以使用 `send_message` 在运行时引导它,或在结束后继续对话。 ```json { @@ -1844,10 +1844,6 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "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": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." } }, "required": [ @@ -1859,7 +1855,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 +注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `provider` 选择后端,通过 `modelSelectionSettings` 读取模型选择设置。每次委派都会立即返回子代理 id;本地子代理接受后续消息,外部后端自动汇报单次执行结果。 @@ -1867,7 +1863,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `interrupt_agent` -根据 agent id 请求取消后台 agent 的当前轮次。目标可以是你的直接子级,也可以是在你下方创建的更深层 agent。只有当前轮次会停止:已经排队发给该 agent 的消息会一直搁置到后续的 send_message;它启动的 agent 会继续运行;该 agent 本身仍可接受后续操作。停止请求被接受后,此调用立即返回,因此目标可能还会短暂运行;中断一个已经完成的 agent 是可接受的空操作。 +根据 agent id 请求取消后台 agent 的当前轮次。目标可以是你的直接子级,也可以是在你下方创建的更深层 agent。对于本地 agent,只有当前轮次会停止:已经排队发给该 agent 的消息会一直搁置到后续的 send_message;它启动的 agent 会继续运行;该 agent 本身仍可接受后续操作。外部执行会永久停止,无法接收后续消息。停止请求被接受后,此调用立即返回,因此目标可能还会短暂运行;中断一个已经完成的 agent 是可接受的空操作。 ```json { @@ -1888,7 +1884,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `list_agents` -按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;idle 表示已加载但处于轮次之间,可能正在等待它启动的 agent;ready 表示它只存在于存储中——可恢复而非终态,也不表示有结果等待收集;`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 idle、ready child 启动轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级会作为诊断信息报告,而不会被静默丢弃。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。 +按 id 和标签列出你的 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;idle 表示已加载但处于轮次之间,可能正在等待它启动的 agent;ready 表示它只存在于存储中——可恢复而非终态,也不表示有结果等待收集;`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 idle、ready child 启动轮次,且无论处于哪种本地状态,直接子级都仍可作为 `send_message` 的目标。外部执行标记 `continuable: false`,状态为 running 或 finished,无法接收后续消息。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级会作为诊断信息报告,而不会被静默丢弃。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。 ```json { @@ -1934,7 +1930,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) -这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 +这些是用于发现、中断托管 subagent 和向本地子代理发送消息的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 @@ -2007,7 +2003,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/jobs/tool-jobs/src/index.ts`](../packages/jobs/tool-jobs/src/index.ts) -与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 +与任务种类无关的后台任务控制器:后台 bash 命令与 PTY 发送 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 diff --git a/packages/acp/acp/src/session.ts b/packages/acp/acp/src/session.ts index 3fff075359..9e18b22dd0 100644 --- a/packages/acp/acp/src/session.ts +++ b/packages/acp/acp/src/session.ts @@ -19,10 +19,10 @@ import { mountAcpMcpServers } from './mcp.ts' import { AcpModelControl } from './model-control.ts' import { assistantUpdates, toolCallUpdate, toolResultUpdate } from './updates.ts' -/** The continuable-subagent teardown used without depending on the subagent package. */ -interface ContinuableDrain { - /** Dispose continuable descendants below exact host-owned parents child-first. */ - drainContinuableDescendants(parents: readonly Agent[]): Promise +/** The subagent teardown used without depending on the subagent package. */ +interface SubagentDrain { + /** Dispose subagent descendants below exact host-owned parents child-first. */ + drainDescendants(parents: readonly Agent[]): Promise } /** Inputs shared by fresh and resumed ACP session construction. */ @@ -439,12 +439,12 @@ export class AcpSession { } catch (error: unknown) { failures.push(new Error('ACP session activity drain failed', { cause: error })) } - const subagents = this.ctx.get('subagents') as ContinuableDrain | undefined + const subagents = this.ctx.get('subagents') as SubagentDrain | undefined try { - await subagents?.drainContinuableDescendants([this.agent]) + await subagents?.drainDescendants([this.agent]) } catch (error: unknown) { - this.ctx.logger.warn(`acp: continuable subagent teardown failed: ${errorChain(error)}`) - failures.push(new Error('continuable subagent teardown failed', { cause: error })) + this.ctx.logger.warn(`acp: subagent teardown failed: ${errorChain(error)}`) + failures.push(new Error('subagent teardown failed', { cause: error })) } try { await this.ctx.sessions.flush(this.agent.session) diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 325255fb81..f607eabd18 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -69,7 +69,7 @@ describe('ACP connection ownership', () => { // 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', { - drainContinuableDescendants: (parents: readonly Agent[]) => { + drainDescendants: (parents: readonly Agent[]) => { drainedParents = parents order.push('drained') return Promise.resolve() @@ -92,7 +92,7 @@ describe('ACP connection ownership', () => { const order: string[] = [] const release = Promise.withResolvers() harness.ctx.provide('subagents', { - drainContinuableDescendants: async () => { + drainDescendants: async () => { order.push('drain started') await release.promise order.push('drain finished') @@ -128,7 +128,7 @@ describe('ACP connection ownership', () => { const warnings: string[] = [] harness.ctx.logger.warn = (message: string) => { warnings.push(message) } harness.ctx.provide('subagents', { - drainContinuableDescendants: () => Promise.reject(new Error('activation teardown failed')), + drainDescendants: () => 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: [] }) @@ -136,7 +136,7 @@ describe('ACP connection ownership', () => { 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(warnings.some(warning => warning.includes('subagent teardown failed'))).toBe(true) expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index d8b1ef6cc4..3fb598dd61 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: d6aee588dee09600c27d602e9ad25c4fde8d370a -README.zh.md: 7bce05e7b79518b3f7c951f1ac29141ac24e2d3a +README.md: d7bcf386a37d740c7336d8cdf9ddfcdfa98823a7 +README.zh.md: 44fed787341080862fd4b2e836163b27197528ff diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index d6aee588de..d7bcf386a3 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -49,7 +49,7 @@ Run `dsh --profile my-profile "your task"` and you get a working agent with mode Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, public HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval; its provider rejects non-public destinations. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The default-on [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path. -Both `subagent` and `subagent_fork` default to continuable background children. They return a child id for later `send_message` calls; explicit `run_in_background: false` still waits for a one-shot result. [Delegation-tool behavior](../../subagent/tool-subagent/README.md) describes both paths. +Both `subagent` and `subagent_fork` create managed children and return a child id immediately. Use `send_message` to steer active children or continue their conversations after completion. [Delegation-tool behavior](../../subagent/tool-subagent/README.md) describes result delivery and backend capabilities. Default file editing uses `read`, `write`, and `edit`. The `str_replace_editor` tool remains available as an explicit opt-in. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch: diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 7bce05e7b7..44fed78734 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -49,7 +49,7 @@ kind: "package-bundle" 开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(提供方与模型可配置,你还可以在设置中启用额外提供方)、完整工具集——文件编辑、shell 命令、web 搜索、公开 HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,其提供方会拒绝非公开目的地址。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范会话日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。默认开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。 -`subagent` 与 `subagent_fork` 都默认创建可继续对话的后台子 agent,并返回 child id,供后续 `send_message` 调用使用;显式传入 `run_in_background: false` 仍会等待一次性结果。[委派工具行为](../../subagent/tool-subagent/README.zh.md)说明了这两条路径。 +`subagent` 与 `subagent_fork` 都创建受管理的子任务,并立即返回 child id。使用 `send_message` 可引导活跃子任务,或在完成后继续其对话。[委派工具行为](../../subagent/tool-subagent/README.zh.md)说明了结果交付和后端能力。 默认文件编辑使用 `read`、`write` 和 `edit`。`str_replace_editor` 工具仍可显式启用。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目: diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 860dd2d912..28c001b8ce 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -358,7 +358,6 @@ config: provider: spawn toolName: subagent - backgroundMode: continuable # Fork omits model selection so provider/model stay equal to the parent and # the inherited history remains eligible for KV Cache reuse. Parent and child @@ -370,7 +369,6 @@ config: provider: fork toolName: subagent_fork - backgroundMode: continuable - id: ptc-runtime name: '@deepseek-ai/dsh-ptc-runtime-node' diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 28621ba30f..1e50f231b8 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -54,7 +54,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^" + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -66,6 +67,12 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^" + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-subagent": { + "optional": true + } } } diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index aecccea08b..151496e2d5 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -29,6 +29,7 @@ import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-cmdline' import type {} from '@deepseek-ai/dsh-session-query' +import type {} from '@deepseek-ai/dsh-subagent' import { internals } from './runner-internals.ts' import { projectJsonRun, boundJsonLine } from './json-stream.ts' @@ -366,7 +367,10 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise content: [{ type: 'text', text: task }], source: { kind: 'user' }, })) - await agent.whenIdle() + const subagents = ctx.get('subagents') + do { + await agent.whenIdle() + } while (await subagents?.waitForChildren(agent)) } finally { stopReasoning?.() } diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 96f646d264..6b89d36c07 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -13,7 +13,7 @@ import type { ResumeAgentOptions, } from '@deepseek-ai/dsh-agent' import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model' -import { LlmAttemptId, ToolCallId, createAssistantMessage, createToolResultMessage, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { LlmAttemptId, ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' @@ -224,6 +224,46 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ } describe('headless runner', () => { + it('waits for later child batches started after settlement wakes the parent', async () => { + const waiting = [Promise.withResolvers(), Promise.withResolvers()] + const released = [Promise.withResolvers(), Promise.withResolvers()] + let turns = 0 + let checks = 0 + const test = await bench({ + afterPrompt(session, message) { + turns += 1 + appendTurn(session, turns, message, turns === 3 ? 'all children complete' : 'waiting for children', true) + }, + }) + test.ctx.provide('subagents', { + async waitForChildren(parent: Agent) { + expect(test.ctx.agents.get(parent.id)).toBe(parent) + const batch = checks++ + if (batch === 2) return false + waiting[batch]!.resolve(undefined) + await released[batch]!.promise + parent.followup(createUserMessage({ content: [{ type: 'text', text: `child batch ${batch + 1} complete` }], source: { kind: 'user' } })) + return true + }, + } as never) + const run = test.run() + try { + await waiting[0]!.promise + expect(test.output().out).toBe('') + released[0]!.resolve(undefined) + await waiting[1]!.promise + expect(turns).toBe(2) + expect(test.output().order).not.toContain('exit') + released[1]!.resolve(undefined) + await expect(run).resolves.toMatchObject({ code: 0, out: 'all children complete\n' }) + expect(checks).toBe(3) + } finally { + for (const release of released) release.resolve(undefined) + await run + await test.ctx.fiber.dispose() + } + }) + it('records a fresh Session in the filesystem provider working directory', async () => { const cwd = '/remote/workspace' const test = await bench({ diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json index e1d1128fea..640ee3b2ad 100644 --- a/packages/bundle/headless/tsconfig.json +++ b/packages/bundle/headless/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../fs/fs" + }, + { + "path": "../../subagent/subagent" } ] } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 5805c00a35..28ff82168c 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -397,8 +397,8 @@ disabled: true # The background-job REGISTRY stays on the host plane; only the model-facing -# `job_*` controls move. Its producers — `tool-bash` here, `tool-terminal` and a -# non-continuable `tool-subagent` elsewhere — are preset rows that resolve it +# `job_*` controls move. Its producers — `tool-bash` here and `tool-terminal` +# elsewhere — are preset rows that resolve it # with `ctx.get`, and an entry-local realm around the registry is invisible to # every sibling row outside that realm, so `run_in_background` answered # "background jobs unavailable" while the controls sat in the catalog. That is diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index e9e00343e6..232e199d3e 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-subagent/README.md -README.md: f4c6da14e5cf3788f2ea0c40902b2dc07558ec7c -README.zh.md: d96b6dfcaadd02514dc65d74a3a1f266a98f1f3c +README.md: f7b784ffba6b4c17026a0ae2b40ef20c4d140cd7 +README.zh.md: ebc186b0f1256f66fb4eaaf3edfa054f4ce52f52 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index f4c6da14e5..f7b784ffba 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use this package to browse every subagent conversation beneath a parent session, open any descendant, and see whether it is running together with its token usage and active-turn duration. Completed one-shot conversations open as read-only execution records. Continuable conversations accept follow-up prompts in submission order while they run and provide Stop independently. The ordinary session sidebar omits subagent conversations, so the parent header catalog is their navigation entry point. The separate `@` source inserts a running child's label into a user message without resolving it into a continuation address. +Use this package to browse local subagent conversations and external executions beneath a parent, with activity, token usage, and duration. Historical one-shot conversations open as read-only execution records. External executions show their status in the catalog and keep their results in the parent conversation. Continuable conversations accept follow-up prompts in submission order while they run and provide Stop independently. The ordinary session sidebar omits subagent conversations, so the parent header catalog is their navigation entry point. The separate `@` source inserts a running child's label into a user message without resolving it into a continuation address. ## Table of Contents @@ -25,11 +25,11 @@ Use this package to browse every subagent conversation beneath a parent session, ## Use this package -The session header keeps the current session title as the lineage breadcrumb and, when the session has subagent descendants, appends a `/` count trigger before the header's action row; the trigger opens the descendant catalog, counts the complete subagent-only lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address. +The session header keeps the current session title as the lineage breadcrumb and, when the session has subagent descendants, appends a `/` count trigger before the header's action row; the trigger opens the descendant catalog, counts the complete subagent-only lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Select a local child at any depth to open its conversation with its exact `{parentSessionId, childSessionId, mode}` address. ### Browsing the tree -Rows display mode plus `running`/`inactive` activity and an optional log-backed title; the trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id; corrupt, unsupported, or unavailable rows remain readable but disabled. +Rows display mode plus `running`/`inactive` activity and an optional log-backed title; the trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id; external execution rows indicate that their result is saved in the parent conversation and do not open a child Session; corrupt, unsupported, or unavailable rows remain readable but disabled. ### Continuing a conversation diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index d96b6dfcaa..ebc186b0f1 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用本包可浏览父会话下的每个 subagent 对话、打开任意后代,并查看其是否正在运行以及 token 用量和活跃轮次耗时。已完成的 one-shot 对话会作为只读执行记录打开。可继续对话在运行期间按提交顺序接收后续提示词,并独立提供 Stop。普通会话侧边栏会省略 subagent 对话,因此父会话页头目录是它们的导航入口。独立的 `@` source 会把运行中 child 的 label 插入用户消息,但不会把它解析成继续执行地址。 +使用本包可浏览父会话下的本地 subagent 对话与外部执行,并查看活动状态、token 用量与耗时。历史 one-shot 对话会作为只读执行记录打开。外部执行在目录中显示状态,结果保存在父对话中。可继续对话在运行期间按提交顺序接收后续提示词,并独立提供 Stop。普通会话侧边栏会省略 subagent 对话,因此父会话页头目录是它们的导航入口。独立的 `@` source 会把运行中 child 的 label 插入用户消息,但不会把它解析成继续执行地址。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 浏览目录 -行显示 mode、`running`/`inactive` 活动状态与由日志支撑的可选 title;尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id;损坏、不受支持或不可用的行仍保持可读但禁用。 +行显示 mode、`running`/`inactive` 活动状态与由日志支撑的可选 title;尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id;外部执行行提示结果保存在父对话中,不会打开子 Session;损坏、不受支持或不可用的行仍保持可读但禁用。 ### 续接对话 diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 1b25072f35..e31a9b1646 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -301,7 +301,7 @@ function CatalogRows({ const label = entry.label ?? entry.id const mode = entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') const activity = entry.activity === 'running' ? t('activity.running') : t('activity.inactive') - const secondary = [summary?.title, mode, activity] + const secondary = [summary?.title, mode, activity, entry.external ? t('external.resultInParent') : undefined] .filter(value => value !== undefined) .join(' · ') const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage) @@ -324,6 +324,7 @@ function CatalogRows({ .join(' · ') const open = (): void => { + if (entry.external) return openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) closeCatalog() } @@ -353,6 +354,7 @@ function CatalogRows({ role="treeitem" tabIndex={0} aria-level={level} + aria-disabled={entry.external || undefined} aria-current={isCurrent || undefined} aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')} {...knownLeaf ? {} : { 'aria-expanded': isExpanded }} diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 312bad3af4..ccebba9f27 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -26,6 +26,7 @@ export const zh = { 'loading.aria': '正在加载子代理', 'load.error': '无法加载子代理', 'retry': '重试', + 'external.resultInParent': '结果保存在父会话中', 'mode.oneShot': '一次性', 'mode.continuable': '可继续', 'activity.running': '正在运行', @@ -67,6 +68,7 @@ export const en: Record = { 'loading.aria': 'Loading subagents', 'load.error': 'Unable to load subagents', 'retry': 'Retry', + 'external.resultInParent': 'Result saved in parent conversation', 'mode.oneShot': 'one-shot', 'mode.continuable': 'continuable', 'activity.running': 'running', diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 1474b4fe5a..b8f2912178 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -148,6 +148,20 @@ describe('SubagentHeaderLineage', () => { expect(trigger.querySelector('[data-state="ongoing"]')).toBeNull() }) + it('shows external execution status without opening a nonexistent child Session', () => { + const input = props(catalog({ entries: [{ + kind: 'child', id: CHILD, mode: 'one-shot', external: true, + label: 'external worker', activity: 'running', hasChildren: false, + }] })) + render() + hoverCatalog(screen.getByRole('button')) + const row = screen.getByRole('treeitem', { name: /external worker.*结果保存在父会话中/ }) + expect(row.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(row) + fireEvent.keyDown(row, { key: 'Enter' }) + expect(input.openChild).not.toHaveBeenCalled() + }) + it('renders healthy counts, stable rows, diagnostics, and catalog-addressed navigation', () => { const input = props(catalog()) render() diff --git a/packages/core/agent-loop/tests/serial-listener-review.spec.ts b/packages/core/agent-loop/tests/serial-listener-review.spec.ts index 88eb59c909..3cead6aa9b 100644 --- a/packages/core/agent-loop/tests/serial-listener-review.spec.ts +++ b/packages/core/agent-loop/tests/serial-listener-review.spec.ts @@ -62,7 +62,7 @@ describe('serial creation listener integrations', () => { await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) const preset = createScope(ctx, { preset: 'review' }) - await preset.ctx.plugin(SubagentTool, { provider: 'spawn', modelSelectionSettings: true, backgroundMode: 'continuable' }) + await preset.ctx.plugin(SubagentTool, { provider: 'spawn', modelSelectionSettings: true }) try { await expect(ctx.agents.create({ sessionId: SessionId('review-tool'), diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index 49ced22dce..67851cf3b2 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -58,6 +58,8 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'step/start', 'subagent/catalog', 'subagent/descriptor', + 'subagent/external-end', + 'subagent/external-start', 'subagent/model-selection-policy', 'system/message', 'team/member', diff --git a/packages/experimental/agent-team/src/roster.ts b/packages/experimental/agent-team/src/roster.ts index ae52c741c2..ead236f346 100644 --- a/packages/experimental/agent-team/src/roster.ts +++ b/packages/experimental/agent-team/src/roster.ts @@ -7,7 +7,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { MessageId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent' -import type { ContinuableStart } from '@deepseek-ai/dsh-subagent' +import type { SubagentActivation } from '@deepseek-ai/dsh-subagent' import { errorMessage, TeamError } from './error.ts' import type { TeamJournal } from './journal.ts' import type { TeamRuntimeLifecycle } from './lifecycle.ts' @@ -239,7 +239,7 @@ export class TeamRoster { * @param childIds - selected roster child ids. */ async stopTeammates(root: Agent, childIds: readonly SessionId[]): Promise { - await this.lifecycle.withTimeout(this.ctx.subagents.drainContinuableChildren(root, childIds)) + await this.lifecycle.withTimeout(this.ctx.subagents.drainChildren(root, childIds)) } /** Perform one creation admitted before the Team runtime disposal cutoff. */ @@ -277,9 +277,10 @@ export class TeamRoster { await this.journal.appendAndFlush(root, 'team/member', { version: 2, teamId: TeamId(root.id), member }) }) - let started: ContinuableStart + let started: SubagentActivation try { - started = await this.ctx.subagents.startContinuable({ + started = await this.ctx.subagents.startActivation({ + delivery: 'parent', childId, provider: request.provider, label: description, @@ -289,6 +290,7 @@ export class TeamRoster { }, signal, }) + if (started.messageId === undefined) throw new Error('teammate backend did not accept an initial inbox message') await this.checkpointInitialPrompt(childId, started.messageId, signal) } catch (error: unknown) { const failed: TeamMemberSnapshot = { diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 5237fc0301..533ada2209 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -195,7 +195,8 @@ for (const backend of backends) { first.ctx.sessions.flush(activeRoot.session), first.ctx.sessions.flush(failedRoot.session), ]) - await first.ctx.subagents.startContinuable({ + await first.ctx.subagents.startActivation({ + delivery: 'parent', childId, provider: 'spawn', label: 'recoverable recovery', diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 79354a6427..d21cf2de56 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -336,7 +336,7 @@ describe('Team identity and provisioning', () => { it('records non-Error provider failures and contains a reversed provisioning settlement race', async () => { const first = await setup([]) - vi.spyOn(first.ctx.subagents, 'startContinuable').mockRejectedValueOnce('string provider failure') + vi.spyOn(first.ctx.subagents, 'startActivation').mockRejectedValueOnce('string provider failure') await expect(spawn(first.ctx, first.lead, 'string-failure')).rejects.toBe('string provider failure') expect(first.ctx.agentTeams.listMembers(first.lead)[1]).toMatchObject({ status: 'failed', @@ -347,7 +347,7 @@ describe('Team identity and provisioning', () => { })).rejects.toMatchObject({ code: 'TEAM_MEMBER_NOT_FOUND' }) const second = await setup([]) - vi.spyOn(second.ctx.subagents, 'startContinuable').mockImplementationOnce(async () => { + vi.spyOn(second.ctx.subagents, 'startActivation').mockImplementationOnce(async () => { const provisioning = durable(second.lead).members[0] if (provisioning === undefined) throw new Error('missing provisioning edge') second.lead.session.append('team/member', { @@ -364,11 +364,11 @@ describe('Team identity and provisioning', () => { it('cleans up a child when recovery settles its provisioning record first', async () => { const { ctx, lead } = await setup(['hang']) - const start = ctx.subagents.startContinuable.bind(ctx.subagents) + const start = ctx.subagents.startActivation.bind(ctx.subagents) const entered = Promise.withResolvers() const release = Promise.withResolvers() let childId: SessionId | undefined - vi.spyOn(ctx.subagents, 'startContinuable').mockImplementation(async (spec) => { + vi.spyOn(ctx.subagents, 'startActivation').mockImplementation(async (spec) => { childId = spec.childId entered.resolve(undefined) await release.promise @@ -390,7 +390,9 @@ describe('Team identity and provisioning', () => { it('handles a continuation that settles before the active roster view or conflict cleanup lookup', async () => { const first = await setup([]) vi.spyOn(teamInternals(first.ctx).roster, 'checkpointInitialPrompt').mockResolvedValueOnce() - vi.spyOn(first.ctx.subagents, 'startContinuable').mockImplementationOnce(async spec => ({ + vi.spyOn(first.ctx.subagents, 'startActivation').mockImplementationOnce(async spec => ({ + result: Promise.resolve({ output: [], stopReason: 'completed' as const }), + dispose: async () => {}, childId: spec.childId!, messageId: createUserMessage({ content: content('accepted'), source: { kind: 'user' } }).id, })) @@ -402,10 +404,12 @@ describe('Team identity and provisioning', () => { vi.spyOn(teamInternals(second.ctx).roster, 'checkpointInitialPrompt').mockResolvedValueOnce() const entered = Promise.withResolvers() const release = Promise.withResolvers() - vi.spyOn(second.ctx.subagents, 'startContinuable').mockImplementationOnce(async (spec) => { + vi.spyOn(second.ctx.subagents, 'startActivation').mockImplementationOnce(async (spec) => { entered.resolve(undefined) await release.promise return { + result: Promise.resolve({ output: [], stopReason: 'completed' as const }), + dispose: async () => {}, childId: spec.childId!, messageId: createUserMessage({ content: content('accepted'), source: { kind: 'user' } }).id, } @@ -486,7 +490,8 @@ describe('Team identity and provisioning', () => { it('rejects stale Agent identities and non-Team subagent children', async () => { const { ctx, lead } = await setup([textResponse('done')]) - const started = await ctx.subagents.startContinuable({ + const started = await ctx.subagents.startActivation({ + delivery: 'parent', provider: 'spawn', label: 'ordinary worker', request: { prompt: content('ordinary'), parent: lead }, @@ -516,7 +521,8 @@ describe('Team identity and provisioning', () => { sessionId: SessionId('temporary-parent'), agentOptions: { provider: 'mock', model: 'mock' }, }) - const started = await first.ctx.subagents.startContinuable({ + const started = await first.ctx.subagents.startActivation({ + delivery: 'parent', provider: 'spawn', label: 'ordinary child', request: { prompt: content('finish'), parent: parent.agent }, @@ -1495,11 +1501,11 @@ describe('Team mailbox and waiting', () => { it('closes creation admission and drains an in-flight spawn before unload completes', async () => { const { ctx, lead, teamFiber } = await setup(['hang']) const service = ctx.agentTeams - const start = ctx.subagents.startContinuable.bind(ctx.subagents) + const start = ctx.subagents.startActivation.bind(ctx.subagents) const entered = Promise.withResolvers() const release = Promise.withResolvers() let childId: SessionId | undefined - vi.spyOn(ctx.subagents, 'startContinuable').mockImplementation(async (spec) => { + vi.spyOn(ctx.subagents, 'startActivation').mockImplementation(async (spec) => { childId = spec.childId entered.resolve(undefined) await release.promise @@ -1577,7 +1583,8 @@ describe('Team mailbox and waiting', () => { teamId: TeamId(lead.id), member, }) - await ctx.subagents.startContinuable({ + await ctx.subagents.startActivation({ + delivery: 'parent', childId, provider: 'spawn', label: member.description, @@ -1701,7 +1708,7 @@ describe('Team mailbox and waiting', () => { const { ctx, lead, teamFiber } = await setup(['hang'], { disposalTimeoutMs: 25 }) const started = await spawn(ctx, lead, 'stuck-worker') await waitRunning(ctx, started.member.id) - const drain = vi.spyOn(ctx.subagents, 'drainContinuableChildren') + const drain = vi.spyOn(ctx.subagents, 'drainChildren') .mockImplementation(() => new Promise(() => {})) const outcome = await Promise.race([ @@ -1783,7 +1790,7 @@ describe('Team mailbox and waiting', () => { const { ctx, lead, teamFiber } = await setup(['hang']) const started = await spawn(ctx, lead, 'failing-drain') await waitRunning(ctx, started.member.id) - vi.spyOn(ctx.subagents, 'drainContinuableDescendants').mockRejectedValueOnce(new Error('drain failure')) + vi.spyOn(ctx.subagents, 'drainDescendants').mockRejectedValueOnce(new Error('drain failure')) await teamFiber.dispose() expect(ctx.get('agentTeams')).toBeUndefined() diff --git a/packages/experimental/auto-review/tests/auto-review.spec.ts b/packages/experimental/auto-review/tests/auto-review.spec.ts index 6816d192e4..1da4d7b69f 100644 --- a/packages/experimental/auto-review/tests/auto-review.spec.ts +++ b/packages/experimental/auto-review/tests/auto-review.spec.ts @@ -1146,7 +1146,6 @@ describe('out-of-process delegation boundary', () => { await ctx.plugin(ToolSubagent, { provider: 'remote-boundary', toolName: 'delegate_remote', - enableRunInBackground: false, maxDepth: 'provider-managed', }) diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index bd09be0221..22b1d11730 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -584,7 +584,8 @@ describe('dsh-tool-team', () => { toolTeam.apply(ctx, {}) expect((await assembly(ctx, lead)).tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort()) .toEqual(TOOL_NAMES) - const ordinary = await ctx.subagents.startContinuable({ + const ordinary = await ctx.subagents.startActivation({ + delivery: 'parent', provider: 'spawn', label: 'ordinary child', request: { prompt: [{ type: 'text', text: 'finish' }], parent: lead }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index eaf21fa553..29b6378b80 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2481,8 +2481,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'subagents', - summary: 'Named provider registry with one-shot runs, durable discovery, and continuable-child operations.', - description: 'Named provider registry with one-shot runs, durable discovery, and continuable-child operations.', + summary: 'Named provider registry with managed activations, durable discovery, and local child messaging.', + description: 'Named provider registry with managed activations, durable discovery, and local child messaging.', methods: [ { signature: 'resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined', @@ -2491,11 +2491,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'The numeric limit, or undefined when the provider owns depth enforcement.', }, { - signature: 'async startContinuable(spec: ContinuableStartSpec): Promise', - description: '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.', - parameters: [{ name: 'spec', description: '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.'], + signature: 'async startActivation(spec: SubagentActivationSpec): Promise', + description: 'Establish a managed child with backend-specific execution capabilities.', + parameters: [{ name: 'spec', description: 'task, backend, and result recipient.' }], + returns: 'identities, execution result, and exact-activation disposal.', + }, + { + signature: 'async waitForChildren(parent: Agent): Promise', + description: 'Wait for this live parent\'s currently owned child work without cancelling it.', + parameters: [{ name: 'parent', description: 'the exact parent whose descendants should settle.' }], + returns: 'whether any work was observed; hosts recheck parent idle after true.', }, { signature: 'async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions, ): Promise', @@ -2506,33 +2511,33 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void', - description: 'Interrupt one live continuable child\'s current turn under a human parent address or an exact live ancestor Agent. Fire-and-return: the cancel signal is issued before this returns, but the target may keep running until it observes the signal. Unclaimed pending inbox work, the Activation, and published descendants are preserved; claimed work is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. An absent target — including a one-shot or unknown id — is an accepted no-op, as is a manager-less composition, which cannot own a live Activation.', + description: 'Interrupt one live child\'s current execution under a human parent address or an exact live ancestor Agent. Fire-and-return: the cancel signal is issued before this returns, but the target may keep running until it observes the signal. Unclaimed pending inbox work, the Activation, and published descendants are preserved; claimed work is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. External backends stop their single execution. An absent target is an accepted no-op, as is a manager-less composition, which cannot own a live Activation.', parameters: [{ name: 'targetSessionId', description: 'the durable child session id to interrupt.' }, { name: 'authority', description: 'the human parent address or exact live ancestor Agent.' }], throws: ['{SubagentError} `UNAUTHORIZED` when the authority does not own the live target.'], }, { - signature: 'async drainContinuableDescendants(parents: readonly Agent[]): Promise', - description: '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.', + signature: 'async drainDescendants(parents: readonly Agent[]): Promise', + description: 'Close subagent 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.', parameters: [{ name: 'parents', description: 'exact host-owned parent Agents entering teardown.' }], - returns: 'once every retained descendant Activation released its `AgentHandle`.', + returns: 'once every retained descendant activation released its driver.', throws: ['an aggregate error after all branches settle when any failed.'], }, { - signature: 'async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise', - description: 'Release selected resident continuable direct children of one exact live parent. Other children of the same parent remain admitted and resident. Absent targets and a manager-less composition are accepted no-ops.', + signature: 'async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise', + description: 'Release selected resident direct children of one exact live parent. Other children of the same parent remain admitted and resident. Absent targets and a manager-less composition are accepted no-ops.', parameters: [{ name: 'parent', description: 'exact live direct parent authorizing the selected release.' }, { name: 'childIds', description: 'durable direct-child ids to release when resident.' }], - returns: 'once every selected Activation released its `AgentHandle`.', + returns: 'once every selected activation released its driver.', throws: ['{SubagentError} `UNAUTHORIZED` when a resident target belongs to a different parent or the supplied parent identity is stale.'], }, { - signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise', - description: 'Enumerate the parent\'s direct session-backed subagents without loading or resuming an Agent. The Session query service supplies one live-preferred corpus and shared point observations; the projection cache supplies immutable descriptor hits without opening cold logs. The registered `subagent` projection remains the sole mode/label classifier.\n\nEvery query receives `signal`, and the listing rechecks cancellation around each await. Read rejections that settle after an abort become a stable `SubagentError` with code `CANCELLED`.', + signature: 'async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise', + description: 'Enumerate the parent\'s direct local and external subagents without loading or resuming an Agent. The Session query service supplies one live-preferred corpus and shared point observations; the projection cache supplies immutable descriptor hits without opening cold logs. The registered `subagent` projection remains the sole mode/label classifier.\n\nEvery query receives `signal`, and the listing rechecks cancellation around each await. Read rejections that settle after an abort become a stable `SubagentError` with code `CANCELLED`.', parameters: [{ name: 'parentSessionId', description: 'parent session whose direct children are listed.' }, { name: 'signal', description: 'caller-owned cancellation forwarded to Session queries and observed around every read await.' }], returns: 'children and per-child diagnostics ordered by `createdAt`, then id.', throws: ['{@link SubagentError} when the projection registry or the session store is not mounted, or the caller cancels the listing.'], }, { - signature: 'listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise', + signature: 'async listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise', description: 'Enumerate the root\'s complete session-backed subagent tree in stable pre-order from one live-preferred corpus, without loading or resuming an Agent. Ordinary sessions and one-shot children remain traversal nodes so continuable descendants below them are discovered; each returned entry adds its durable `parentId` and root-relative `depth`. Identity resolution, diagnostics, optional persistence, and cancellation follow the same projection-backed contract as listChildren.', parameters: [{ name: 'rootSessionId', description: 'session whose complete descendant tree is listed.' }, { name: 'signal', description: 'caller-owned cancellation forwarded to persistence reads and observed around every read await.' }], returns: 'children and per-candidate diagnostics with tree position, in stable pre-order.', @@ -2577,12 +2582,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'the registered names.', }, - { - signature: 'async start(name: string, request: SubagentStartRequest): Promise', - description: 'Establish a published child on the named provider. Capability and semantic checks run before delegation. Provider ownership lasts until its promise fulfills; a rejection therefore has no run for the caller to dispose and emits no run lifecycle events. Post-publication turn and infrastructure failures settle through the returned run. A catalog append failure disposes the run and handles its result rejection; the caller receives the catalog error even if disposal also fails.', - parameters: [{ name: 'name', description: 'the provider to use.' }, { name: 'request', description: 'child label, prompt, parent, signal, and optional capabilities.' }], - returns: 'the published holder-owned run.', - }, ], }, { @@ -4291,14 +4290,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContinuableCreateSpec', declaration: 'export interface ContinuableCreateSpec {\n readonly seed?: readonly SessionEvent[];\n}', }, - { - name: 'ContinuableStart', - 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 childId?: SessionId;\n readonly request: Omit;\n readonly signal: AbortSignal;\n}', - }, { name: 'ContinuableSubagentDescriptorData', declaration: 'export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'continuable\';\n readonly label: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly agentReasoningEffort?: ReasoningEffortId;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}', @@ -4797,7 +4788,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'JobKindMap', - declaration: 'export interface JobKindMap {\n bash: \'bash\';\n subagent: \'subagent\';\n}', + declaration: 'export interface JobKindMap {\n bash: \'bash\';\n}', }, { name: 'JobOutcome', @@ -6219,6 +6210,14 @@ 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: ToolCallId;\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?: ReplayEnvelope;\n};', }, + { + name: 'SubagentActivation', + declaration: 'export interface SubagentActivation {\n readonly childId: SessionId;\n readonly messageId?: MessageId;\n readonly result: Promise;\n dispose(): Promise;\n}', + }, + { + name: 'SubagentActivationSpec', + declaration: 'export interface SubagentActivationSpec {\n readonly provider: string;\n readonly label: string;\n readonly childId?: SessionId;\n readonly request: Omit;\n readonly signal: AbortSignal;\n readonly delivery: \'parent\' | \'caller\';\n}', + }, { name: 'SubagentCapabilities', declaration: 'export interface SubagentCapabilities {\n readonly agentOptions: boolean;\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', @@ -6245,7 +6244,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentListEntry', - declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n readonly hasChildren: boolean;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};', + declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n readonly hasChildren: boolean;\n} & ({\n readonly mode: \'one-shot\';\n readonly external?: undefined;\n readonly label?: string;\n} | {\n readonly mode: \'one-shot\';\n readonly external: true;\n readonly label: string;\n} | {\n readonly mode: \'continuable\';\n readonly external?: undefined;\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};', }, { name: 'SubagentPromptReceipt', @@ -6261,7 +6260,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentProvider', - declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n readonly agentRouteDefaults?: Readonly<{\n provider: string;\n model: string;\n }>;\n start(request: ResolvedSubagentStartRequest): Promise;\n prepareContinuable?(request: ContinuableCreateRequest): Promise;\n}', + declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n readonly agentRouteDefaults?: Readonly<{\n provider: string;\n model: string;\n }>;\n start?(request: ResolvedSubagentStartRequest): Promise;\n prepareContinuable?(request: ContinuableCreateRequest): Promise;\n}', }, { name: 'SubagentResult', @@ -6285,7 +6284,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRuntime', - declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config: z;\n constructor(ctx: Context, config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote(\'list\')\n async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, re /* …truncated — full shape in source */', + declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config: z;\n constructor(ctx: Context, config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n async startActivation(spec: SubagentActivationSpec): Promise;\n async waitForChildren(parent: Agent): Promise;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainDescendants(parents: readonly Agent[]): Promise;\n async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n async listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote(\'list\')\n async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n /* …truncated — full shape in source */', }, { name: 'SubagentSendMessageOptions', diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index dafd91844f..c7da19a3e1 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -12,6 +12,7 @@ import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' declare module '@deepseek-ai/dsh-jobs' { interface JobKindMap { + 'test-task': 'test-task' workflow: 'workflow' } } @@ -266,7 +267,7 @@ describe('LocalJobRegistry.start', () => { const ctx = await harness() expect(ctx.jobs.start(producer().spec)).toBe('bash-1') expect(ctx.jobs.start(producer().spec)).toBe('bash-2') - expect(ctx.jobs.start(producer({ kind: 'subagent' }).spec)).toBe('subagent-1') + expect(ctx.jobs.start(producer({ kind: 'test-task' }).spec)).toBe('test-task-1') expect(ctx.jobs.start(producer({ kind: 'workflow' }).spec)).toBe('workflow-1') }) }) @@ -301,7 +302,7 @@ describe('LocalJobRegistry reads and settlement', () => { it('final-output kinds read empty while live, the outcome output idempotently once settled', async () => { const ctx = await harness() - const p = producer({ kind: 'subagent', label: 'research job' }) + const p = producer({ kind: 'test-task', label: 'research job' }) const id = ctx.jobs.start(p.spec) expect(ctx.jobs.read(id)).toMatchObject({ text: '', snapshot: { status: 'running' } }) @@ -314,7 +315,7 @@ describe('LocalJobRegistry reads and settlement', () => { it('a settled job without output reads as empty text', async () => { const ctx = await harness() - const p = producer({ kind: 'subagent' }) + const p = producer({ kind: 'test-task' }) const id = ctx.jobs.start(p.spec) p.settle({ status: 'failed', detail: 'max-tokens' }) await tick() @@ -587,7 +588,7 @@ describe('LocalJobRegistry owner isolation', () => { const aliceTask = ctx.jobs.start(producer({ owner: alice }).spec) const bobTask = ctx.jobs.start(producer({ owner: bob }).spec) - const openTask = ctx.jobs.start(producer({ kind: 'subagent' }).spec) + const openTask = ctx.jobs.start(producer({ kind: 'test-task' }).spec) expect(ctx.jobs.list(alice).map(t => t.id)).toEqual([aliceTask, openTask]) expect(ctx.jobs.list(bob).map(t => t.id)).toEqual([bobTask, openTask]) @@ -671,7 +672,7 @@ describe('LocalJobRegistry owner cleanup', () => { let settle!: (outcome: JobOutcome) => void const cancels: (string | undefined)[] = [] ctx.jobs.start({ - kind: 'subagent', + kind: 'test-task', label: 'long research', owner, run: () => ({ @@ -719,7 +720,7 @@ describe('LocalJobRegistry owner cleanup', () => { let settle!: (outcome: JobOutcome) => void ctx.jobs.start({ - kind: 'subagent', + kind: 'test-task', label: 'long research', owner, run: () => ({ diff --git a/packages/jobs/jobs/src/types.ts b/packages/jobs/jobs/src/types.ts index 2842f6a3a5..610239913a 100644 --- a/packages/jobs/jobs/src/types.ts +++ b/packages/jobs/jobs/src/types.ts @@ -22,7 +22,6 @@ export type JobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'faile */ export interface JobKindMap { bash: 'bash' - subagent: 'subagent' } /** The merge-extensible union of registered producer kind names. */ @@ -44,7 +43,7 @@ export interface JobOutcome { * execution resources while the runtime owns identity and lifecycle state. */ export interface JobStart { - /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ + /** Producer kind — also the id prefix (`bash`, …). */ kind: JobKind /** One-line model-facing label (the command; the delegation description). */ label: string diff --git a/packages/jobs/jobs/tests/invariant.spec.ts b/packages/jobs/jobs/tests/invariant.spec.ts index 5cbaf94b11..7dff07908f 100644 --- a/packages/jobs/jobs/tests/invariant.spec.ts +++ b/packages/jobs/jobs/tests/invariant.spec.ts @@ -7,6 +7,12 @@ import type { JobDoneListener, JobSnapshot } from '@deepseek-ai/dsh-jobs' import * as JobsInvariant from '@deepseek-ai/dsh-jobs/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' +declare module '@deepseek-ai/dsh-jobs' { + interface JobKindMap { + 'test-task': 'test-task' + } +} + const BASE: JobSnapshot = { id: JobId('bash-1'), kind: 'bash', @@ -60,7 +66,7 @@ describe('job-registry invariants', () => { const notify = await setup([RUNNING]) expect(() => { notify(BASE) }).not.toThrow() const owner = { id: SessionId('owner') } as Agent - expect(() => { notify({ ...BASE, id: JobId('subagent-2'), kind: 'subagent', ownerSession: owner.id }, owner) }) + expect(() => { notify({ ...BASE, id: JobId('test-task-2'), kind: 'test-task', ownerSession: owner.id }, owner) }) .not.toThrow() }) diff --git a/packages/jobs/tool-jobs/README.i18n.yaml b/packages/jobs/tool-jobs/README.i18n.yaml index f7986ed7c0..23308a157e 100644 --- a/packages/jobs/tool-jobs/README.i18n.yaml +++ b/packages/jobs/tool-jobs/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/jobs/tool-jobs/README.md -README.md: 8f3de4276d766f931f9461d25d15bf33aec26856 -README.zh.md: 83f8fca0cd7e74f4c4687351752bd8c36b002647 +README.md: 7ff0734e76cad83193ac9a76e21ab83f63996b16 +README.zh.md: 118b605ac0e37338d357f142819359b2efd0c7f1 diff --git a/packages/jobs/tool-jobs/README.md b/packages/jobs/tool-jobs/README.md index 8f3de4276d..7ff0734e76 100644 --- a/packages/jobs/tool-jobs/README.md +++ b/packages/jobs/tool-jobs/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-tool-jobs` to inspect and control background commands, PTY work, and subagents through `job_output`, `job_list`, and `job_kill`. Reads can wait within a configured timeout, list results identify each job's kind and status, and cancellation settles only after the work stops. When owned work finishes, the agent receives an in-session notice: busy agents receive it in their next step, while idle agents may be woken by a bounded follow-up turn. Configuration controls wait limits, completion delivery, and consecutive wakeups. Stream output is consumed by one reader, and pending notices do not survive owner disposal. +Use `dsh-tool-jobs` to inspect and control background commands and PTY work through `job_output`, `job_list`, and `job_kill`. Reads can wait within a configured timeout, list results identify each job's kind and status, and cancellation settles only after the work stops. When owned work finishes, the agent receives an in-session notice: busy agents receive it in their next step, while idle agents may be woken by a bounded follow-up turn. Configuration controls wait limits, completion delivery, and consecutive wakeups. Stream output is consumed by one reader, and pending notices do not survive owner disposal. ## Table of Contents @@ -74,7 +74,7 @@ This section explains the design decisions behind the tools and points at the co ### Design philosophy -- **Kind-independent controls.** The same three tools read, list, and cancel jobs of every producer kind — bash, subagent, PTY — because all of them register through the generic `ctx.jobs` runtime. +- **Kind-independent controls.** The same three tools read, list, and cancel jobs of every producer kind — bash, PTY — because all of them register through the generic `ctx.jobs` runtime. - **Delivery is owned here; recipients are the registry's.** The plugin decides how an unreported completion reaches the owner — injected into a busy step, or a woken turn on an idle owner — while the registry routes each settlement to the listeners its owner's scope chain reaches, so a mount under one preset never sees another preset's agents, and an agent reads exactly one notice per completion however many presets are mounted. - **Producer-owned output bounds.** When a producer supplies `outputLimitBytes`, the complete model-facing result — output read, terminal kill snapshot, or completion notice — is capped after status and notice metadata are added; producers that omit it keep unbounded behavior. diff --git a/packages/jobs/tool-jobs/README.zh.md b/packages/jobs/tool-jobs/README.zh.md index 83f8fca0cd..118b605ac0 100644 --- a/packages/jobs/tool-jobs/README.zh.md +++ b/packages/jobs/tool-jobs/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `dsh-tool-jobs`,可通过 `job_output`、`job_list` 与 `job_kill` 检查和控制后台命令、PTY 工作与 subagent。读取可在配置的超时内等待,列表结果标识各任务的 kind 与状态,而取消只有在工作停止后才结算。归属明确的工作完成时,agent(智能体)会收到会话内通知:繁忙的 agent 在下一步收到通知,空闲的 agent 则可能由有界的 follow-up 轮次唤醒。配置控制等待上限、完成投递与连续唤醒次数。流输出仅供单一读取方消费,待领通知无法在所有者释放后存活。 +使用 `dsh-tool-jobs`,可通过 `job_output`、`job_list` 与 `job_kill` 检查和控制后台命令与 PTY 工作。读取可在配置的超时内等待,列表结果标识各任务的 kind 与状态,而取消只有在工作停止后才结算。归属明确的工作完成时,agent(智能体)会收到会话内通知:繁忙的 agent 在下一步收到通知,空闲的 agent 则可能由有界的 follow-up 轮次唤醒。配置控制等待上限、完成投递与连续唤醒次数。流输出仅供单一读取方消费,待领通知无法在所有者释放后存活。 ## 目录 @@ -74,7 +74,7 @@ kind: "package-reference" ### 设计理念 -- **与 kind 无关的控制。** 同一套三个工具读取、列出和取消每种生产方 kind 的任务——bash、subagent、PTY——因为它们都通过通用的 `ctx.jobs` 运行时注册。 +- **与 kind 无关的控制。** 同一套三个工具读取、列出和取消每种生产方 kind 的任务——bash、PTY——因为它们都通过通用的 `ctx.jobs` 运行时注册。 - **投递归本插件,收件人归注册表。** 插件决定未报告的完成如何到达所有者——注入繁忙的一步,或唤醒空闲所有者的一轮——而注册表把每次结算路由给其所有者 scope 链所能抵达的监听器,因此某个 preset 下的挂载永远看不到另一个 preset 的 agent,无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知。 - **生产方自有的输出上限。** 生产方提供 `outputLimitBytes` 时,完整的模型侧结果——输出读取、终止 kill 快照或完成通知——会在添加状态与通知元数据之后被施加上限;省略该字段的生产方保持无界行为。 diff --git a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts index 6f32878207..06b47d1b07 100644 --- a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts +++ b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts @@ -14,6 +14,12 @@ import type { JobHooks, JobOutcome, JobSnapshot, JobStart } from '@deepseek-ai/d import * as ToolJobs from '@deepseek-ai/dsh-tool-jobs' import { statusLine } from '@deepseek-ai/dsh-tool-jobs' +declare module '@deepseek-ai/dsh-jobs' { + interface JobKindMap { + 'test-task': 'test-task' + } +} + const testToolSignal = new AbortController().signal const agentRegistryDisposers = new WeakMap Promise>() @@ -198,13 +204,13 @@ describe('job_output', () => { it('returns the final output of a settled final-output job', async () => { const { ctx } = await setup() - const p = producer({ kind: 'subagent', label: 'research' }) + const p = producer({ kind: 'test-task', label: 'research' }) ctx.jobs.start(p.spec) - expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('(no new output)\n[status: running]') + expect(text(await call(ctx, 'job_output', { job_id: 'test-task-1' }))).toBe('(no new output)\n[status: running]') p.settle({ status: 'completed', detail: 'completed', output: 'the answer' }) await tick() - expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('the answer\n[status: completed, completed]') + expect(text(await call(ctx, 'job_output', { job_id: 'test-task-1' }))).toBe('the answer\n[status: completed, completed]') }) it('applies a producer limit to the complete body and status result', async () => { @@ -321,10 +327,10 @@ describe('job_output', () => { it('wait: true blocks until settlement and reports the terminal state', async () => { const { ctx } = await setup() - const p = producer({ kind: 'subagent', label: 'research' }) + const p = producer({ kind: 'test-task', label: 'research' }) ctx.jobs.start(p.spec) - const pending = call(ctx, 'job_output', { job_id: 'subagent-1', wait: true }) + const pending = call(ctx, 'job_output', { job_id: 'test-task-1', wait: true }) p.settle({ status: 'completed', output: 'done deal' }) expect(text(await pending)).toBe('done deal\n[status: completed]') }) @@ -355,7 +361,7 @@ describe('job_list', () => { const alice = await fakeAgent(ctx, 'sess-alice') ctx.jobs.start(producer({ owner: alice, label: 'pnpm test' }).spec) - ctx.jobs.start(producer({ kind: 'subagent', label: 'open research' }).spec) + ctx.jobs.start(producer({ kind: 'test-task', label: 'open research' }).spec) const p = producer({ owner: alice, label: 'build' }) ctx.jobs.start(p.spec) p.settle({ status: 'completed', detail: 'exit code: 0' }) @@ -373,12 +379,12 @@ describe('job_list', () => { } expect(text(listed)).toBe([ 'bash-1 [bash] running — pnpm test', - 'subagent-1 [subagent] running — open research', + 'test-task-1 [test-task] running — open research', 'bash-2 [bash] completed — build', ].join('\n')) // A different caller sees only the unowned job. const bob = await fakeAgent(ctx, 'sess-bob') - expect(text(await call(ctx, 'job_list', {}, bob))).toBe('subagent-1 [subagent] running — open research') + expect(text(await call(ctx, 'job_list', {}, bob))).toBe('test-task-1 [test-task] running — open research') }) }) @@ -500,8 +506,8 @@ describe('tool-owned UI presentation (presentCall)', () => { .toEqual({ card: 'generic', title: 'Read output from background job bash-1', kind: 'read', rawInput: 'bash-1' }) expect(ctx.tools.get('job_list')?.presentCall?.({})) .toEqual({ card: 'generic', title: 'List background jobs', kind: 'read' }) - expect(ctx.tools.get('job_kill')?.presentCall?.({ job_id: 'subagent-2' })) - .toEqual({ card: 'generic', title: 'Kill background job subagent-2', kind: 'execute', rawInput: 'subagent-2' }) + expect(ctx.tools.get('job_kill')?.presentCall?.({ job_id: 'test-task-2' })) + .toEqual({ card: 'generic', title: 'Kill background job test-task-2', kind: 'execute', rawInput: 'test-task-2' }) }) }) @@ -711,7 +717,7 @@ describe('completion notices', () => { const owner = await fakeAgent(ctx, 'sess-1', { inject }) const first = producer({ owner, - kind: 'subagent', + kind: 'test-task', label: 'x'.repeat(1_000), outputLimitBytes: 61, }) @@ -724,21 +730,21 @@ describe('completion notices', () => { { id: expect.any(String) as unknown, role: 'user', - content: [{ type: 'text', text: 'background job subagent-1\nDone; job_output.' }], + content: [{ type: 'text', text: 'background job test-task-1\nDone; job_output.' }], // The label and status detail are unbounded caller text, so the durable // one-line account caps itself rather than committing their full length. source: { kind: 'plugin', plugin: 'tool-jobs', form: 'notice', - summary: `subagent ${'x'.repeat(110)}…`, + summary: `test-task ${'x'.repeat(109)}…`, }, }, ) const second = producer({ owner, - kind: 'subagent', + kind: 'test-task', label: 'x'.repeat(1_000), outputLimitBytes: 80, }) @@ -749,7 +755,7 @@ describe('completion notices', () => { const content = (inject.mock.calls[1]?.[0] as { content?: Array<{ type: string; text?: string }> } | undefined)?.content const notice = content?.[0]?.text ?? '' expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(80) - expect(notice).toContain('background job subagent-2 (subagent: xxxx') + expect(notice).toContain('background job test-task-2 (test-task: xxxx') expect(notice).toContain('[notice truncated]\nDone; job_output.') }) @@ -818,10 +824,10 @@ describe('completion notices', () => { const { ctx } = await setup() const inject = vi.fn() const owner = await fakeAgent(ctx, 'sess-1', { inject }) - const p = producer({ owner, kind: 'subagent' }) + const p = producer({ owner, kind: 'test-task' }) ctx.jobs.start(p.spec) - const pending = call(ctx, 'job_output', { job_id: 'subagent-1', wait: true }, owner) + const pending = call(ctx, 'job_output', { job_id: 'test-task-1', wait: true }, owner) p.settle({ status: 'completed', output: 'answer' }) expect(text(await pending)).toContain('answer') expect(inject).not.toHaveBeenCalled() diff --git a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml index 9df711310c..936b8581e3 100644 --- a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml +++ b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml @@ -180,7 +180,6 @@ provider: spawn toolName: subagent modelSelectionSettings: true - backgroundMode: continuable # Fork omits model selection so provider/model stay equal to the parent and # the inherited history remains eligible for KV Cache reuse. This preset @@ -191,7 +190,6 @@ config: provider: fork toolName: subagent_fork - backgroundMode: continuable # Production dsh does not install these optional providers. Install the # matching Bundle in this Profile and restart the Host, then copy this @@ -203,7 +201,6 @@ config: provider: codex toolName: subagent_codex - backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code @@ -212,7 +209,6 @@ config: provider: claude-code toolName: subagent_claude_code - backgroundMode: one-shot maxDepth: provider-managed - id: workflow-ptc diff --git a/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md b/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md index 727099f6fc..6f7b6dd2a4 100644 --- a/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -80,7 +80,6 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o config: provider: codex toolName: subagent_codex - backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code @@ -89,13 +88,12 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o config: provider: claude-code toolName: subagent_claude_code - backgroundMode: one-shot maxDepth: provider-managed ``` For additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. -The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings. +The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. Each tool returns a subagent id immediately; the activation manager delivers its final answer and completion status. External product backends do not accept follow-up messages. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings. ## What not to move into a preset diff --git a/packages/preset/agent-presets/presets/ptc/agent.cordis.yml b/packages/preset/agent-presets/presets/ptc/agent.cordis.yml index 8f0e0ae716..b26ee515e3 100644 --- a/packages/preset/agent-presets/presets/ptc/agent.cordis.yml +++ b/packages/preset/agent-presets/presets/ptc/agent.cordis.yml @@ -191,7 +191,6 @@ provider: spawn toolName: subagent modelSelectionSettings: true - backgroundMode: continuable # Fork omits model selection so provider/model stay equal to the parent and # the inherited history remains eligible for KV Cache reuse. This preset @@ -202,7 +201,6 @@ config: provider: fork toolName: subagent_fork - backgroundMode: continuable # Production dsh does not install these optional providers. Install the # matching Bundle in this Profile and restart the Host, then copy this @@ -214,7 +212,6 @@ config: provider: codex toolName: subagent_codex - backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code @@ -223,7 +220,6 @@ config: provider: claude-code toolName: subagent_claude_code - backgroundMode: one-shot maxDepth: provider-managed - id: workflow-ptc diff --git a/packages/preset/agent-presets/presets/standard/agent.cordis.yml b/packages/preset/agent-presets/presets/standard/agent.cordis.yml index b4e81ec1e9..dc76b5cf71 100644 --- a/packages/preset/agent-presets/presets/standard/agent.cordis.yml +++ b/packages/preset/agent-presets/presets/standard/agent.cordis.yml @@ -184,7 +184,6 @@ provider: spawn toolName: subagent modelSelectionSettings: true - backgroundMode: continuable # Fork omits model selection so provider/model stay equal to the parent and # the inherited history remains eligible for KV Cache reuse. This preset @@ -195,7 +194,6 @@ config: provider: fork toolName: subagent_fork - backgroundMode: continuable # Production dsh does not install these optional providers. Install the # matching Bundle in this Profile and restart the Host, then copy this @@ -207,7 +205,6 @@ config: provider: codex toolName: subagent_codex - backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code @@ -216,7 +213,6 @@ config: provider: claude-code toolName: subagent_claude_code - backgroundMode: one-shot maxDepth: provider-managed - id: workflow-ptc diff --git a/packages/sdk/server/tests/built-scope-carrier.e2e.ts b/packages/sdk/server/tests/built-scope-carrier.e2e.ts index 52f908a55b..36fa032724 100644 --- a/packages/sdk/server/tests/built-scope-carrier.e2e.ts +++ b/packages/sdk/server/tests/built-scope-carrier.e2e.ts @@ -3,7 +3,7 @@ * `dsh-sdk-jsonrpc-server`. The carrier registry is module-local, so both bundles must * externalize `dsh-scope`; source-mode tests cannot expose an accidentally * inlined second registry. This test runs the real `lib/index.js` bundles in a - * plain Node subprocess, disposes the child before settlement, and requires the + * plain Node subprocess, settles a child with an unavailable model, and requires the * SDK completion notification to retain the delegating parent. */ @@ -61,35 +61,22 @@ try { meta: { cwd: storageRoot }, agentOptions: { model: "test" }, }); - const child = await parent.agent.ctx.agents.create({ - sessionId: SessionId("built-child"), - meta: { cwd: storageRoot, parentSession: SessionId("built-parent") }, - agentOptions: { model: "test" }, - parentAgent: parent.agent, - }); - const result = Promise.withResolvers(); const unregister = ctx.subagents.registerProvider({ name: "built-local", capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, - start() { - return Promise.resolve({ - id: child.agent.id, - localAgent: child.agent, - result: result.promise, - dispose() { return Promise.resolve(); }, - }); - }, + prepareContinuable() { return Promise.resolve({}); }, }); - const run = await ctx.subagents.start("built-local", { - parent: parent.agent, - prompt: [], + const run = await ctx.subagents.startActivation({ + provider: "built-local", + childId: SessionId("built-child"), + label: "built scope carrier", + delivery: "caller", + request: { parent: parent.agent, prompt: [{ type: "text", text: "Use the unavailable test model." }] }, signal: new AbortController().signal, }); - await child.dispose(); - result.resolve({ output: [], stopReason: "completed" }); await run.result; - await Promise.resolve(); + await run.dispose(); console.log(JSON.stringify(notifications.filter(({ method }) => method === "subagent.finished"))); await run.dispose(); @@ -119,8 +106,8 @@ describe.skipIf(!existsSync(jsonrpcBundle))('dsh-sdk-jsonrpc-server BUILT scope agentId: 'built-child', parentSessionId: 'built-parent', childSessionId: 'built-child', - status: 'ok', - stopReason: 'completed', + status: 'error', + stopReason: 'error', }, }]) }) diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 3448c5dc87..23c74d1592 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -8,6 +8,8 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import { randomUUID } from 'node:crypto' import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -15,7 +17,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import SubagentRuntime, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' +import SubagentRuntime, { snapshotSubagentDescriptor, SubagentRunId, type SubagentStartRequest, type SubagentRun, type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' import { HarnessSdkJsonRpcServer } from '../src/index.ts' @@ -68,7 +70,22 @@ async function makeHarness(storageDir: string) { return ctx } -/** Drive the owning service so test lifecycle events carry the real parent scope. */ +/** Feed SDK lifecycle inputs without coupling protocol projection tests to activation admission. */ +async function startLifecycleFixture(ctx: Context, providerName: string, request: SubagentStartRequest): Promise { + const provider = ctx.subagents.getProvider(providerName) + if (provider?.start === undefined) throw new Error('missing lifecycle fixture provider') + const run = await provider.start({ ...request, descriptor: snapshotSubagentDescriptor({ mode: 'one-shot', provider: providerName, label: providerName }) }) + const identity = { runId: SubagentRunId(randomUUID()), provider: providerName, id: run.id, local: run.localAgent !== undefined } + const carrier = scopeTarget(ctx.subagents, request.parent) + ctx.emit(carrier, 'subagent/start', identity) + void run.result.then( + (result) => { ctx.emit(carrier, 'subagent/end', { ...identity, stopReason: result.stopReason, ...result.output.length > 0 ? { lastAssistantMessage: result.output } : {} }) }, + () => { ctx.emit(carrier, 'subagent/end', { ...identity, stopReason: 'error' }) }, + ) + return run +} + +/** Settle a scoped lifecycle fixture after the requested registry change. */ async function settleSubagent( ctx: Context, parent: Agent, @@ -90,7 +107,7 @@ async function settleSubagent( }, }) try { - const run = await ctx.subagents.start(info.provider, { + const run = await startLifecycleFixture(ctx, info.provider, { parent, prompt: [], signal: new AbortController().signal, @@ -618,12 +635,12 @@ describe('HarnessSdkJsonRpcServer', () => { }, }) - const firstRun = await ctx.subagents.start('reused', { + const firstRun = await startLifecycleFixture(ctx, 'reused', { parent: oldParent.agent, prompt: [], signal: new AbortController().signal, }) - const sameLifetimeRun = await ctx.subagents.start('reused', { + const sameLifetimeRun = await startLifecycleFixture(ctx, 'reused', { parent: oldParent.agent, prompt: [], signal: new AbortController().signal, @@ -643,7 +660,7 @@ describe('HarnessSdkJsonRpcServer', () => { parentAgent: newParent.agent, }) currentLocalAgent = newChild.agent - const secondRun = await ctx.subagents.start('reused', { + const secondRun = await startLifecycleFixture(ctx, 'reused', { parent: newParent.agent, prompt: [], signal: new AbortController().signal, @@ -714,7 +731,7 @@ describe('HarnessSdkJsonRpcServer', () => { dispose: () => Promise.resolve(), }), }) - const localRun = await ctx.subagents.start('reused-provider', { + const localRun = await startLifecycleFixture(ctx, 'reused-provider', { parent: parent.agent, prompt: [], signal: new AbortController().signal, @@ -732,7 +749,7 @@ describe('HarnessSdkJsonRpcServer', () => { dispose: () => Promise.resolve(), }), }) - const remoteRun = await ctx.subagents.start('reused-provider', { + const remoteRun = await startLifecycleFixture(ctx, 'reused-provider', { parent: parent.agent, prompt: [], signal: new AbortController().signal, @@ -816,7 +833,7 @@ describe('HarnessSdkJsonRpcServer', () => { }) // Start before the server subscribes. The terminal payload still carries // this run's exact local child without reconstructing it from ids. - const missedStartRun = await ctx.subagents.start('fork', { + const missedStartRun = await startLifecycleFixture(ctx, 'fork', { parent: parentHandle.agent, prompt: [], signal: new AbortController().signal, diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 693f42f93c..cc5c3295a9 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: 265aec1072dcec736ea9d6a9be3d20f67ac4361e -README.zh.md: 4cf907d4df6d178aac8e43174e8ded973716b1cf +README.md: 2cca2059a4d4ec2644416c3e3d5c44fbd42818e0 +README.zh.md: 93af6a316fea622eb2782ef162274a5c0ed6a2c4 diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 265aec1072..2cca2059a4 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -25,7 +25,6 @@ The subagent package family lets an agent delegate a task to a child, continue t | Package | Role | ctx key | |---|---|---| | [`subagent/`](subagent/README.md) | Defines the delegation service: provider registry, one-shot runs, continuable children, and discovery | `ctx.subagents` | -| [`subagent-in-process-driver/`](subagent-in-process-driver/README.md) | Provides the shared in-process run driver | — | | [`subagent-spawn-in-process/`](subagent-spawn-in-process/README.md) | Runs a fresh in-process child | registers on `ctx.subagents` | | [`subagent-fork-in-process/`](subagent-fork-in-process/README.md) | Runs an in-process child seeded from the parent's completed history | registers on `ctx.subagents` | | [`subagent-acp/`](subagent-acp/README.md) | Runs an out-of-process child over the Agent Client Protocol | registers on `ctx.subagents` | diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 4cf907d4df..93af6a316f 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -25,7 +25,6 @@ subagent 包家族让 agent(智能体)将任务委派给子 agent、继续 | 包 | 职责 | ctx 键 | |---|---|---| | [`subagent/`](subagent/README.zh.md) | 定义委派服务:提供方注册表、一次性运行、可继续子级与发现 | `ctx.subagents` | -| [`subagent-in-process-driver/`](subagent-in-process-driver/README.zh.md) | 提供共享的进程内运行驱动器 | 无 | | [`subagent-spawn-in-process/`](subagent-spawn-in-process/README.zh.md) | 运行全新的进程内子 agent | 注册到 `ctx.subagents` | | [`subagent-fork-in-process/`](subagent-fork-in-process/README.zh.md) | 运行从父级已完成历史派生的进程内子 agent | 注册到 `ctx.subagents` | | [`subagent-acp/`](subagent-acp/README.zh.md) | 经 Agent Client Protocol 运行进程外子 agent | 注册到 `ctx.subagents` | diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index b425d88095..b2b0365fe4 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-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/subagent/subagent-acp/README.md -README.md: 87fb7f3101d2d329300213bed2acb4a479c83b72 -README.zh.md: 72b94cc89d8318930cc89c4eac34bb2557a2ad58 +README.md: 32324f7472540480f367eb99f3acad2b887dd4e9 +README.zh.md: d7970fe3d63c2a0368f653342d0864424667dc37 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 87fb7f3101..32324f7472 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -91,7 +91,7 @@ This section explains how the backend drives a child over ACP and where the obse ### Start and ownership flow -A start resolves the child's working directory (the configured `cwd` override, else the parent session's cwd), spawns the command through the subprocess seam, performs the ACP `initialize` and `newSession` handshake, and only then publishes the run. Fulfillment means a remote session is ready and ownership has transferred to the caller. Disposal is idempotent: it closes stdin and waits a configured grace for cooperative quiescence, then escalates through SIGTERM to SIGKILL and awaits whole-range exit. Cleanup failures remain observable as ordered safe facts and never claim quiescence. +A start resolves the child's working directory (the configured `cwd` override, else the parent session's cwd), spawns the command through the subprocess seam, performs the ACP `initialize` and `newSession` handshake, and only then publishes the run. Fulfillment transfers the external run to the activation manager. The startup signal detaches after acceptance; the activation receipt cancels execution and awaits cleanup. Each activation runs one task and cannot receive follow-up input. Disposal is idempotent: it closes stdin and waits a configured grace for cooperative quiescence, then escalates through SIGTERM to SIGKILL and awaits whole-range exit. Cleanup failures remain observable as ordered safe facts and never claim quiescence. ### Stop-reason mapping @@ -139,11 +139,11 @@ Independent of the parent request cache. Each ACP child can reuse only prefixes #### What the model sees -Through `dsh-tool-subagent`, the parent receives only the child's final streamed assistant text or that consumer's exact stop-reason error, not intermediate messages or tool traffic. Non-completed results present the safe diagnostic before separately preserved partial assistant output. A request already cancelled before publication becomes exactly `Error: subagent request was aborted before the ACP child started`; another start failure contains only the fixed `Subagent failure (...)` line. +Through `dsh-tool-subagent`, the parent model first receives a child id, then a completion notice containing final or partial assistant text, the stop reason, and safe diagnostic. The parent Session independently retains the external task identity and complete terminal result. Intermediate messages and tool traffic do not enter the parent Session. #### Token effect -Parent input grows only by the final result or error, which is data-dependent and retained until compaction. This provider adds no parent schema itself. +Parent input grows by the start acknowledgement and completion notice, whose size depends on the result data and persists until compaction. This provider adds no parent schema by itself. #### KV Cache effect diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 72b94cc89d..d7970fe3d6 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -91,7 +91,7 @@ spawn、初始化或新建会话失败会在发布前拒绝,通常先证明 ma ### 启动与所有权流程 -一次启动先解析子 agent 的工作目录(配置的 `cwd` 覆盖值,否则取父会话 cwd),经子进程 seam spawn 命令,完成 ACP `initialize` 与 `newSession` 握手,然后才发布运行。兑现意味着远程会话已就绪、所有权已转移给调用方。dispose(资源释放)是幂等的:先关闭 stdin 并按配置的宽限等待协作式完全停稳,再经 SIGTERM 升级到 SIGKILL,并等待整个 managed range 退出。清理失败会作为有序的安全事实保持可观察,且绝不声称已经完全停稳。 +一次启动先解析子 agent 的工作目录(配置的 `cwd` 覆盖值,否则取父会话 cwd),经子进程 seam spawn 命令,完成 ACP `initialize` 与 `newSession` 握手,然后才发布运行。兑现后,外部运行由 activation 管理器接管。任务接受后会解除启动信号的绑定;activation 凭据用于取消执行并等待清理。每个 activation 只运行一个任务,不能接收追加输入。dispose(资源释放)是幂等的:先关闭 stdin 并按配置的宽限等待协作式完全停稳,再经 SIGTERM 升级到 SIGKILL,并等待整个 managed range 退出。清理失败会作为有序的安全事实保持可观察,且绝不声称已经完全停稳。 ### 停止原因映射 @@ -139,11 +139,11 @@ spawn、初始化或新建会话失败会在发布前拒绝,通常先证明 ma #### 模型看到什么 -通过 `dsh-tool-subagent`,父级只接收子 agent 最终的流式 assistant 文本或该消费方给出的精确停止原因错误,不接收中间消息或工具流量。未完成的结果会先呈现安全诊断,再单独保留部分 assistant 输出。发布前已经取消的请求会精确变为 `Error: subagent request was aborted before the ACP child started`;其他启动失败只包含固定的 `Subagent failure (...)` 行。 +通过 `dsh-tool-subagent`,父模型先收到 child id,随后收到含最终或部分 assistant 文本、停止原因与安全诊断的完成通知。父会话独立保存外部任务身份和完整终态结果。中间消息与工具通信不进入父会话。 #### Token 影响 -父级输入只增加最终结果或错误,其内容依赖数据,并保留到压缩(compaction)为止。本提供方自身不会添加父级 schema。 +父级输入增加启动确认与完成通知,内容大小取决于数据,并保留到压缩(compaction)为止。本提供方自身不会向父级添加任何 schema。 #### KV Cache 影响 diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 75f962ca40..33ee1c8f54 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -1,3 +1,4 @@ +import { startExternalActivation } from '../../subagent/tests/external-activation-helpers.ts' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -70,7 +71,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive env: childLaunch.env as Record, }) - const run = await ctx.subagents.start('acp', { + const run = await startExternalActivation(ctx, 'acp', { prompt: [{ type: 'text', text: 'Reply with exactly the word PONG and nothing else. Do not use any tools.' }], parent: fakeParent, signal: new AbortController().signal, @@ -103,7 +104,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive env: childLaunch.env as Record, }) - const run = await ctx.subagents.start('acp', { + const run = await startExternalActivation(ctx, 'acp', { prompt: [{ type: 'text', text: 'Use the bash tool to write the text ACP_CHILD_WAS_HERE into a file named proof.txt ' + 'in the current directory. Then reply DONE.' }], diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 6db46da475..298d97feff 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1,3 +1,4 @@ +import { startExternalActivation } from '../../subagent/tests/external-activation-helpers.ts' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -6,7 +7,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { PassThrough, type Readable } from 'node:stream' import { fileURLToPath } from 'node:url' -import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SubagentRuntime, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -243,7 +244,7 @@ describe('child env layering (through the subprocess seam)', () => { // explicit entry merges after it and the child must see the value. const ctx = await setup({ MOCK_ECHO_ENV: 'DSH_ACP_TEST_FACT', DSH_ACP_TEST_FACT: 'managed' }) const parent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent - const run = await ctx.subagents.start('acp', { + const run = await startExternalActivation(ctx, 'acp', { label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal, }) const result = await run.result @@ -462,7 +463,7 @@ describe('cwd resolution', () => { try { const ctx = await setup({ MOCK_ECHO_CWD: '1' }) const parent = { id: 'parent', session: { header: { cwd: workdir } } } as unknown as Agent - const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }) + const run = await startExternalActivation(ctx, 'acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }) const result = await run.result await run.dispose() // Line 1: where the child process actually ran; line 2: the workspace the @@ -484,7 +485,7 @@ describe('cwd resolution', () => { // A command that would create the sentinel if the child were ever spawned. await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} }) const parent = { id: 'parent', session: { header: {} } } as unknown as Agent - await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) + await expect(startExternalActivation(ctx, 'acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) .rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`) // Resolution failed BEFORE the process boundary — nothing was launched. expect(existsSync(sentinel)).toBe(false) @@ -510,7 +511,7 @@ describe('cwd resolution', () => { env: { MOCK_ECHO_CWD: '1' }, }) const parent = { id: 'parent', session: { header: { cwd: parentDir } } } as unknown as Agent - const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }) + const run = await startExternalActivation(ctx, 'acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }) const result = await run.result await run.dispose() expect(text(result.output)).toBe(`${configured}\n${configured}`) @@ -538,7 +539,7 @@ describe('cwd resolution', () => { permission: 'reject', env: { MOCK_ECHO_CWD: '1' }, }) - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result await run.dispose() expect(text(result.output)).toBe(`${realpathSync(absolute)}\n${absolute}`) @@ -604,14 +605,17 @@ describe('cwd resolution', () => { await ctx.fiber.dispose() }) - it('rejects a parent session cwd that is not absolute', async () => { - // SessionHeader documents cwd as absolute; a relative value here is a broken - // header, and resolving it against the server process cwd would silently - // re-introduce the launch-directory dependency this resolution removes. + + it('rejects a relative parent directory before spawning', async () => { const ctx = await setup({}) - const parent = { id: 'parent', session: { header: { cwd: 'relative/workspace' } } } as unknown as Agent - await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) - .rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`) + const provider = ctx.subagents.getProvider('acp')! + const spawn = vi.spyOn(ctx.subprocess, 'spawn') + await expect(Promise.resolve().then(() => provider.start!({ + ...request(), + parent: { id: 'relative-parent', session: { header: { cwd: 'relative-workspace' } } } as unknown as Agent, + descriptor: snapshotSubagentDescriptor({ mode: 'one-shot', provider: provider.name }), + }))).rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`) + expect(spawn).not.toHaveBeenCalled() }) it('rejects a parent session cwd that names a FILE, not a directory', async () => { @@ -621,7 +625,7 @@ describe('cwd resolution', () => { try { const ctx = await setup({}) const parent = { id: 'parent', session: { header: { cwd: file } } } as unknown as Agent - await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) + await expect(startExternalActivation(ctx, 'acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) .rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`) } finally { rmSync(tmp, { recursive: true, force: true }) @@ -638,7 +642,7 @@ describe('cwd resolution', () => { await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} }) const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent - await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) + await expect(startExternalActivation(ctx, 'acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) .rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`) expect(existsSync(sentinel)).toBe(false) } finally { @@ -650,8 +654,8 @@ describe('cwd resolution', () => { describe('dsh-subagent-acp', () => { it('drives child processes with parent-unique run ids and returns streamed output', async () => { const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn', MOCK_SESSION_ID: 'acp-child-session' }) - const run = await ctx.subagents.start('acp', request('do X')) - expect(run.id).not.toBe('acp-child-session') + const run = await startExternalActivation(ctx, 'acp', request('do X')) + expect(run.childId).not.toBe('acp-child-session') const result = await run.result expect(result.stopReason).toBe('completed') expect(result.diagnostic).toBeUndefined() @@ -660,16 +664,16 @@ describe('dsh-subagent-acp', () => { expect(run.dispose()).toBe(disposal) await disposal - const nextRun = await ctx.subagents.start('acp', request('do X again')) - expect(nextRun.id).not.toBe(run.id) - expect(nextRun.id).not.toBe('acp-child-session') + const nextRun = await startExternalActivation(ctx, 'acp', request('do X again')) + expect(nextRun.childId).not.toBe(run.childId) + expect(nextRun.childId).not.toBe('acp-child-session') await nextRun.result await nextRun.dispose() }) it('maps a max_tokens stop reason', async () => { const ctx = await setup({ MOCK_TEXT: 'cut off', MOCK_STOP: 'max_tokens' }) - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result expect(result.stopReason).toBe('max-tokens') expect(result.diagnostic).toBeUndefined() @@ -678,7 +682,7 @@ describe('dsh-subagent-acp', () => { it('maps a refusal stop reason', async () => { const ctx = await setup({ MOCK_TEXT: '', MOCK_STOP: 'refusal' }) - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result expect(result.stopReason).toBe('refusal') expect(result.diagnostic).toBeUndefined() @@ -695,7 +699,7 @@ describe('dsh-subagent-acp', () => { MOCK_TOOL_KIND: 'read', MOCK_STOP: remote, }, 'reject') - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result expect(result.stopReason).toBe(stopReason) expect(result.diagnostic).toBe(expectedPermission('reject', 'read', 'denied')) @@ -704,14 +708,14 @@ describe('dsh-subagent-acp', () => { it('keeps an ordinary remote cancelled stop diagnostic-free', async () => { const ctx = await setup({ MOCK_STOP: 'cancelled' }) - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'mock child answer' }], stopReason: 'aborted' }) await run.dispose() }) it('preserves max_turn_requests as an actionable remote limit', async () => { const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_STOP: 'max_turn_requests' }) - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result expect(result).toEqual({ output: [{ type: 'text', text: 'partial' }], @@ -724,7 +728,7 @@ describe('dsh-subagent-acp', () => { it('uses a fixed fallback for an unknown remote stop reason', async () => { const rawReason = 'private/path/SECRET_TOKEN' const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_STOP: rawReason }) - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result expect(result.stopReason).toBe('error') expect(result.diagnostic).toBe( @@ -734,17 +738,17 @@ describe('dsh-subagent-acp', () => { await run.dispose() }) - it('aborting the required signal cancels a running child', async ({ task }) => { + it('disposing an activation cancels a running child', async ({ task }) => { const tmp = mkdtempSync(join(tmpdir(), 'acp-cancel-')) const readyFile = join(tmp, 'ready') try { const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile }) const controller = new AbortController() - const run = await ctx.subagents.start('acp', request('p', controller.signal)) + const run = await startExternalActivation(ctx, 'acp', request('p', controller.signal)) // Wait until the child's prompt is in flight (condition, not a sleep), // then cancel — so we exercise the mid-run session/cancel path. await waitForFile(readyFile, task.timeout) - controller.abort('test') + void run.dispose() const result = await run.result expect(result.stopReason).toBe('aborted') expect(result.diagnostic).toBeUndefined() @@ -775,16 +779,18 @@ describe('dsh-subagent-acp', () => { } }) - it('rejects a pre-aborted request through the registered provider before cwd resolution', async () => { + it('rejects a pre-aborted provider request before resolving the parent directory', async () => { const ctx = await setup() const controller = new AbortController() controller.abort() const parent = { id: 'parent', session: { header: {} } } as unknown as Agent - await expect(ctx.subagents.start('acp', { + const provider = ctx.subagents.getProvider('acp')! + await expect(Promise.resolve().then(() => provider.start!({ prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: controller.signal, - })).rejects.toThrow('subagent request was aborted before the ACP child started') + descriptor: snapshotSubagentDescriptor({ mode: 'one-shot', provider: provider.name }), + }))).rejects.toThrow('aborted before the ACP child started') }) it('reports an initialize-stage process exit without copying the transport error', async () => { @@ -1107,7 +1113,7 @@ describe('dsh-subagent-acp', () => { try { const ctx = await setup({ MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go, MOCK_TEXT: 'should not run' }) const controller = new AbortController() - const starting = ctx.subagents.start('acp', request('p', controller.signal)) + const starting = startExternalActivation(ctx, 'acp', request('p', controller.signal)) // Observe rejection before readiness; cleanup still owns an unpublished // child if readiness or an assertion fails. const settled = starting.then( @@ -1134,15 +1140,15 @@ describe('dsh-subagent-acp', () => { } }) - it('bridges the request signal to a session/cancel mid-run', async ({ task }) => { + it('bridges activation disposal to session/cancel mid-run', async ({ task }) => { const tmp = mkdtempSync(join(tmpdir(), 'acp-signal-')) const readyFile = join(tmp, 'ready') try { const controller = new AbortController() const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile }) - const run = await ctx.subagents.start('acp', request('p', controller.signal)) + const run = await startExternalActivation(ctx, 'acp', request('p', controller.signal)) await waitForFile(readyFile, task.timeout) - controller.abort() + void run.dispose() const result = await run.result expect(result.stopReason).toBe('aborted') expect(result.diagnostic).toBeUndefined() @@ -1154,7 +1160,7 @@ describe('dsh-subagent-acp', () => { it('auto-rejects a permission prompt by default (child settles cancelled→aborted)', async () => { const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1', MOCK_TOOL_KIND: 'execute' }, 'reject') - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result // The child asked permission, the backend rejected, the child returned cancelled. expect(result.stopReason).toBe('aborted') @@ -1164,7 +1170,7 @@ describe('dsh-subagent-acp', () => { it('auto-approves a permission prompt under the allow policy', async () => { const ctx = await setup({ MOCK_TEXT: 'approved answer', MOCK_PERMISSION: '1', MOCK_STOP: 'end_turn' }, 'allow') - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result expect(result.stopReason).toBe('completed') expect(result.diagnostic).toBeUndefined() @@ -1176,7 +1182,7 @@ describe('dsh-subagent-acp', () => { // The child asks permission but offers ONLY reject-shaped options, so an // allow-policy client finds nothing to select and must answer cancelled. const ctx = await setup({ MOCK_PERMISSION: '1', MOCK_NO_ALLOW: '1' }, 'allow') - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result expect(result.stopReason).toBe('aborted') expect(result.diagnostic).toBe(expectedPermission('allow', 'unknown', 'denied')) @@ -1190,7 +1196,7 @@ describe('dsh-subagent-acp', () => { MOCK_TOOL_KIND: 'edit', MOCK_STOP: 'max_turn_requests', }, 'reject') - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result expect(result.stopReason).toBe('error') expect(result.diagnostic).toBe( @@ -1207,7 +1213,7 @@ describe('dsh-subagent-acp', () => { MOCK_TOOL_KIND: 'execute', MOCK_STOP: 'max_turn_requests', }, 'allow') - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result expect(result.stopReason).toBe('error') expect(result.diagnostic).toBe( @@ -1221,7 +1227,7 @@ describe('dsh-subagent-acp', () => { // The child streams an agent_thought_chunk before its answer; the backend // must consume it but NOT include it in the result output. const ctx = await setup({ MOCK_THOUGHT: '1', MOCK_TEXT: 'final answer', MOCK_STOP: 'end_turn' }) - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result expect(result.stopReason).toBe('completed') // Only the message text, NOT the thought. @@ -1432,7 +1438,7 @@ describe('dsh-subagent-acp', () => { disposeEofGraceMs: 5_000, disposeGraceMs: 5_000, }) - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) expect(start).toHaveBeenCalledExactlyOnceWith(expect.anything(), expect.objectContaining({ disposeEofGraceMs: 5_000, disposeGraceMs: 5_000, @@ -1475,7 +1481,7 @@ describe('dsh-subagent-acp', () => { permission: 'reject', env: {}, }) - await expect(ctx.subagents.start('acp', request())).rejects.toThrow( + await expect(startExternalActivation(ctx, 'acp', request())).rejects.toThrow( `subagent-acp: ${expectedFailure('stage: initialize; category: transport')}`, ) }) @@ -1601,7 +1607,7 @@ describe('dsh-subagent-acp', () => { const ctx = await setup({ MOCK_CRASH_ON_PROMPT: '1' }) const warnings: string[] = [] ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - const run = await ctx.subagents.start('acp', request()) + const run = await startExternalActivation(ctx, 'acp', request()) const result = await run.result expect(result.stopReason).toBe('error') expect(result.diagnostic).toBe( @@ -1651,9 +1657,9 @@ describe('dsh-subagent-acp', () => { try { const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_CRASH_ON_CANCEL: '1', MOCK_READY_FILE: ready }) const controller = new AbortController() - const run = await ctx.subagents.start('acp', request('p', controller.signal)) + const run = await startExternalActivation(ctx, 'acp', request('p', controller.signal)) await waitForFile(ready, task.timeout) - controller.abort('crash it') + void run.dispose() const result = await run.result expect(result.stopReason).toBe('aborted') expect(result.diagnostic).toBeUndefined() @@ -1663,8 +1669,8 @@ describe('dsh-subagent-acp', () => { } }) - it('settles aborted on signal even when the child IGNORES session/cancel', async ({ task }) => { - // The signal contract requires `result` to settle `aborted`. A child that hangs + it('settles aborted on disposal even when the child ignores session/cancel', async ({ task }) => { + // Activation disposal must settle `result` as `aborted`. A child that hangs // its prompt AND ignores session/cancel must not wedge the parent — the // backend's own cancel-settle path resolves `aborted` without the child's // cooperation, and dispose() still reaps the process. @@ -1673,9 +1679,9 @@ describe('dsh-subagent-acp', () => { try { const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1', MOCK_READY_FILE: ready }) const controller = new AbortController() - const run = await ctx.subagents.start('acp', request('p', controller.signal)) + const run = await startExternalActivation(ctx, 'acp', request('p', controller.signal)) await waitForFile(ready, task.timeout) - controller.abort('test') + void run.dispose() // Bound it: a regression (cancel only notifies the child, which ignores it) // would hang result forever — fail loud instead of stalling the suite. const result = await Promise.race([ diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 5c6051045a..06bfd2b061 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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-claude-code/README.md -README.md: 20bb0847fb688c37b6508b6eb335a23f3b442922 -README.zh.md: eabdadb243acf7f5ccdd81420268b259283182dc +README.md: 9eea8f8f444e7ff33aaa2ddf5ec8d59a1a90e0f3 +README.zh.md: 36f98e8bedc3fb1d608fd88481b038c8cba64b03 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 20bb0847fb..9eea8f8f44 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -64,24 +64,19 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Each delegation tool row names one provider and needs its own `toolName`, so the model sees static tools rather than a dynamic provider selector. Full Agent Presets carry a matching default tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. ```yaml -- id: jobs - name: '@deepseek-ai/dsh-jobs-local' -- id: tool-jobs - name: '@deepseek-ai/dsh-tool-jobs' - id: tool-subagent-claude name: '@deepseek-ai/dsh-tool-subagent' config: provider: claude-code toolName: subagent_claude_code - backgroundMode: one-shot maxDepth: provider-managed ``` -The `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned job id for `job_output` or `job_kill`; the base host and full presets already provide the generic Job registry and controls. +The tool returns a child id after accepting the task and sends its result to the parent Agent on completion. Each external activation executes once and accepts no follow-up input or resume. ### What you get -A foreground call gives the model the strict final Claude Code answer, or an error with the stop reason and optional safe diagnostic for a failed run. A background call first returns a job id; the generic job controls later deliver a completion notice and expose the same final answer or failed status through `job_output`. Claude Code reasoning, tool activity, intermediate messages, stderr, and workspace diffs never enter the parent session. +The completion notice contains the final Claude Code answer, or the stop reason and optional safe diagnostic. The parent Session also retains the external task identity and complete terminal result independently of the notice; no local child Session is created. Product reasoning, tool activity, raw stderr, and workspace diffs do not enter the parent Session. ### Failure and recovery @@ -154,15 +149,15 @@ Independent of the parent request cache. Reuse depends only on Claude Code's own #### What the model sees -Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. That diagnostic can distinguish a coarse action category, lifecycle stage, and observed process outcome without copying raw product text or version-specific subtype names. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the same final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, product ids, tool inputs, and raw protocol payloads are not copied into the parent Session. +Through `dsh-tool-subagent`, the parent model first receives a child id, then the final Claude Code answer or a failure notice with its stop reason and safe diagnostic. Diagnostics contain only fixed stage, category, and observed protocol or process facts. Product reasoning, intermediate messages, tool activity, stderr, usage, product identifiers, commands, paths, and raw protocol payloads are not copied into the parent Session. #### Token effect -Foreground input grows by the retained final answer or error. Background input also includes the start acknowledgement, completion notice, and any `job_output`, `job_kill`, or later status results; child tokens still do not enter the parent context. This provider adds no parent tool schema by itself. +Parent input grows by the start acknowledgement and completion notice, including the final answer or failure detail. Child tokens do not enter the parent context. This provider adds no parent tool schema by itself. #### KV Cache effect -Append-only: foreground adds one result after the reusable parent prefix, while background appends the Job acknowledgement, notice, and later control or collection results. Background scheduling can add a notice-driven turn, but none of these messages rewrites the earlier prefix. +Start acknowledgements and completion notices append after the reusable parent request prefix. A notice may wake another turn without rewriting the existing prefix. ## Known Limitations and Deferred Work @@ -177,7 +172,7 @@ These limits define when this provider is a poor fit or needs special operationa - **Authentication and account state remain native** — the Bundle supplies the CLI but does not create an account, log in, or rewrite Claude settings; configuration and authentication failures surface with their lifecycle stage and the safe `unknown` fallback rather than a separate public classification. - **The SDK platform payload is required at delegation time** — installs that omit optional dependencies, unsupported platforms, and missing or damaged payloads fail at the first query; there is no host-CLI fallback. - **No human interaction path** — `AskUserQuestion` is disabled, permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of suspending. -- **Assistant payload is final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. +- **Assistant payload is final text only** — failed runs can also expose a separate safe diagnostic; reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain outside the parent Session. Task identity and terminal results are retained in the parent log. - **No optional shared capabilities** — `agentOptions`, output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index eabdadb243..36f98e8bed 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -64,24 +64,19 @@ dsh --profile 每个委派工具行指名一个提供方,并需要独立的 `toolName`,因此模型看到的是静态工具,而不是动态提供方选择器。完整 Agent Preset 携带对应的默认工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent(智能体)暴露 `subagent_claude_code`。 ```yaml -- id: jobs - name: '@deepseek-ai/dsh-jobs-local' -- id: tool-jobs - name: '@deepseek-ai/dsh-tool-jobs' - id: tool-subagent-claude name: '@deepseek-ai/dsh-tool-subagent' config: provider: claude-code toolName: subagent_claude_code - backgroundMode: one-shot maxDepth: provider-managed ``` -`one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 job id,供 `job_output` 或 `job_kill` 使用;base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 +工具接受任务后返回 child id;任务完成后向父 agent 发送结果通知。外部 activation 只执行一次,不支持追加输入或恢复对话。 ### 你会得到什么 -前台调用会把严格的最终 Claude Code 答案交给模型;运行失败时则返回带停止原因与可选安全诊断的错误。后台调用先返回 job id;随后通用作业控制面会送达完成通知,并通过 `job_output` 公开同一最终答案或失败状态。Claude Code 的推理、工具活动、中间消息、stderr 与工作区差异绝不会进入父级会话。 +完成通知包含 Claude Code 的最终答案,或停止原因与可选的安全诊断。父会话还独立保存外部任务的身份和完整终态结果;无需创建本地子 Session。产品推理、工具活动、原始 stderr 与工作区差异不会进入父会话。 ### 失败与恢复 @@ -154,15 +149,15 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 #### 模型看到什么 -通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。该诊断可以区分粗粒度行动类别、生命周期阶段和已观测的进程结果,而不复制原始产品文本或版本专属 subtype 名称。后台调用会先返回 job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开同一最终答案或失败状态详情,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息、产品标识符、工具输入和原始协议载荷均不会复制到父会话。 +通过 `dsh-tool-subagent`,父模型先收到 child id,随后收到 Claude Code 最终答案或带停止原因与安全诊断的失败通知。诊断只包含固定的阶段、类别和已观测的协议或进程事实。产品推理、中间消息、工具活动、stderr、用量、产品标识符、命令、路径与原始协议载荷不会复制到父 Session。 #### Token 影响 -前台输入会增加工具结果中保留的最终答案或错误内容。后台输入还会包含启动确认、完成通知,以及 `job_output`、`job_kill` 或后续状态结果;子任务 token 仍不会进入父级上下文。本提供方自身不添加父级工具 schema。 +父级输入会增加启动确认与完成通知,包括最终答案或失败信息。子任务 token 不进入父级上下文。本提供方自身不添加父级工具 schema。 #### KV Cache 影响 -仅追加:前台会在可复用的父请求前缀后增加一个结果,后台则会继续追加 Job 启动确认、通知以及后续控制或收集结果。后台调度可能增加一个由通知唤醒的轮次,但这些消息都不会改写更早的前缀。 +启动确认与完成通知仅追加到可复用的父请求前缀之后。通知可能唤醒新轮次,不会改写已有前缀。 ## 已知限制与延期工作 @@ -177,7 +172,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 - **身份验证与账户状态仍由原生机制管理**——Bundle 会提供 CLI,但不会创建账户、登录或改写 Claude 设置;配置与身份验证失败会公开其生命周期阶段与安全的 `unknown` 回退,而不会增加单独的公开分类。 - **委派时必须存在 SDK 平台载荷**——省略 optional dependencies 的安装、不受支持的平台以及缺失或损坏的载荷都会在第一次 query 时失败;不会回退到宿主 CLI。 - **没有人工交互路径**——`AskUserQuestion` 被禁用,权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会以拒绝方式失败而不会挂起。 -- **assistant 载荷仅包含最终文本**——失败运行可以额外公开独立的安全诊断;推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部,通用 Job id、通知与状态来自共享作业运行时。 +- **assistant 载荷仅包含最终文本**——失败运行还可公开独立的安全诊断;推理、中间消息、工具通信、用量、stderr 与工作区差异不会进入父 Session。任务身份与终态结果保存在父日志中。 - **没有可选的共享能力**——对于本提供方,共享服务会拒绝 `agentOptions`、输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 - **没有按实际经过时间触发的超时或副作用回滚**——长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-claude-code/tests/fixtures/loader/claude-code.patch.yml b/packages/subagent/subagent-claude-code/tests/fixtures/loader/claude-code.patch.yml index 720419869c..1ccf9b6c8a 100644 --- a/packages/subagent/subagent-claude-code/tests/fixtures/loader/claude-code.patch.yml +++ b/packages/subagent/subagent-claude-code/tests/fixtures/loader/claude-code.patch.yml @@ -30,7 +30,6 @@ config: provider: codex toolName: subagent_codex - backgroundMode: one-shot maxDepth: 'provider-managed' - id: tool-subagent-claude-code @@ -38,7 +37,6 @@ config: provider: claude-code toolName: subagent_claude_code - backgroundMode: one-shot maxDepth: 'provider-managed' - id: tool-subagent-claude-primary @@ -46,7 +44,6 @@ config: provider: claude-primary toolName: subagent_claude_primary - backgroundMode: one-shot maxDepth: 'provider-managed' - id: tool-subagent-claude-secondary @@ -54,5 +51,4 @@ config: provider: claude-secondary toolName: subagent_claude_secondary - backgroundMode: one-shot maxDepth: 'provider-managed' diff --git a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts index c9d0aa613c..7d5bc2c8b6 100644 --- a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -91,22 +91,22 @@ describe('product-provider public Loader composition', () => { tools: [ { name: 'subagent_codex', - parameterNames: ['description', 'prompt', 'run_in_background'], + parameterNames: ['description', 'prompt'], required: ['description', 'prompt'], }, { name: 'subagent_claude_code', - parameterNames: ['description', 'prompt', 'run_in_background'], + parameterNames: ['description', 'prompt'], required: ['description', 'prompt'], }, { name: 'subagent_claude_primary', - parameterNames: ['description', 'prompt', 'run_in_background'], + parameterNames: ['description', 'prompt'], required: ['description', 'prompt'], }, { name: 'subagent_claude_secondary', - parameterNames: ['description', 'prompt', 'run_in_background'], + parameterNames: ['description', 'prompt'], required: ['description', 'prompt'], }, ], diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts index eab7445ea5..9966156aeb 100644 --- a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts @@ -1,3 +1,4 @@ +import { startExternalActivation } from '../../subagent/tests/external-activation-helpers.ts' import { execFile } from 'node:child_process' import { randomUUID } from 'node:crypto' import { @@ -130,7 +131,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( id: 'deepseek-e2e-parent', session: { header: { cwd: workspace } }, } as unknown as Agent - const run = await ctx.subagents.start('claude-code', { + const run = await startExternalActivation(ctx, 'claude-code', { prompt: [{ type: 'text', text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`, diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 279c844e0b..06870fef38 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -1,3 +1,4 @@ +import { startExternalActivation } from '../../subagent/tests/external-activation-helpers.ts' import { execFile } from 'node:child_process' import { existsSync, @@ -279,7 +280,7 @@ function startRequest( prompt: string, signal = new AbortController().signal, ) { - return harness.ctx.subagents.start('claude-code', { + return startExternalActivation(harness.ctx, 'claude-code', { prompt: [{ type: 'text', text: prompt }], parent: harness.parent, signal, @@ -412,12 +413,12 @@ describe('real Claude Agent SDK 0.3.263 and its distributed Claude Code 2.1.263 const safeController = new AbortController() const [safeRun, bypassRun] = await Promise.all([ - ctx.subagents.start('claude-safe', { + startExternalActivation(ctx, 'claude-safe', { prompt: [{ type: 'text', text: 'Hold the safe instance.' }], parent: safeParent, signal: safeController.signal, }), - ctx.subagents.start('claude-bypass', { + startExternalActivation(ctx, 'claude-bypass', { prompt: [{ type: 'text', text: 'Complete the bypass instance.' }], parent: bypassParent, signal: new AbortController().signal, @@ -426,7 +427,7 @@ describe('real Claude Agent SDK 0.3.263 and its distributed Claude Code 2.1.263 await safeInstance.fixture.requestStarted await safeFiber.dispose() expect(ctx.subagents.list()).toEqual(['claude-bypass']) - await expect(ctx.subagents.start('claude-safe', { + await expect(startExternalActivation(ctx, 'claude-safe', { prompt: [{ type: 'text', text: 'This start must fail.' }], parent: safeParent, signal: new AbortController().signal, @@ -436,7 +437,7 @@ describe('real Claude Agent SDK 0.3.263 and its distributed Claude Code 2.1.263 output: [{ type: 'text', text: 'NAMED_BYPASS_RESULT' }], stopReason: 'completed', }) - safeController.abort(new Error('cancel only the published safe run')) + void safeRun.dispose() await expect(safeRun.result).resolves.toEqual({ output: [], stopReason: 'aborted', @@ -561,7 +562,7 @@ describe('real Claude Agent SDK 0.3.263 and its distributed Claude Code 2.1.263 controller.signal, ) await fixture.requestStarted - controller.abort(new Error('real product cancellation')) + void run.dispose() await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted', diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index f26335a088..0a812d5e65 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1,3 +1,4 @@ +import { startExternalActivation } from '../../subagent/tests/external-activation-helpers.ts' import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { PassThrough } from 'node:stream' @@ -24,7 +25,7 @@ import { } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SubagentRuntime, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubprocessHandle, @@ -412,6 +413,28 @@ describe('task admission and package contracts', () => { .toThrow('must not be empty') }) + it.each([false, true])('rejects an invalid parent directory before spawning (cancelled: %s)', async (cancelled) => { + const ctx = new Context() + try { + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + await ctx.plugin(claudeCode, {}) + const spawn = vi.spyOn(ctx.subprocess, 'spawn') + const controller = new AbortController() + if (cancelled) controller.abort() + const provider = ctx.subagents.getProvider('claude-code')! + await expect(Promise.resolve().then(() => provider.start!({ + ...request(undefined, controller.signal), + parent: { id: 'invalid-cwd-parent', session: { header: { cwd: 'relative-workspace' } } } as unknown as Agent, + descriptor: snapshotSubagentDescriptor({ mode: 'one-shot', provider: provider.name }), + }))).rejects.toThrow(cancelled + ? 'request was aborted before SDK startup' + : 'stage: query-start; category: unknown') + expect(spawn).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } + }) + it('registers the default descriptor, validates config, and unregisters on HMR', async () => { const ctx = new Context() await ctx.plugin(SessionProjectionRegistry) @@ -498,20 +521,20 @@ describe('task admission and package contracts', () => { const safeController = new AbortController() const [safeRun, bypassRun] = await Promise.all([ - ctx.subagents.start('claude-safe', request(undefined, safeController.signal)), - ctx.subagents.start('claude-bypass', request()), + startExternalActivation(ctx, 'claude-safe', request(undefined, safeController.signal)), + startExternalActivation(ctx, 'claude-bypass', request()), ]) await safeFiber.dispose() expect(ctx.subagents.list()).toEqual(['claude-bypass']) expect(removed).toEqual(['claude-safe']) - await expect(ctx.subagents.start('claude-safe', request())) + await expect(startExternalActivation(ctx, 'claude-safe', request())) .rejects.toMatchObject({ code: 'NO_PROVIDER' }) await expect(bypassRun.result).resolves.toEqual({ output: [{ type: 'text', text: 'bypass answer' }], stopReason: 'completed', }) - safeController.abort(new Error('stop only the safe instance')) + void safeRun.dispose() await expect(safeRun.result).resolves.toEqual({ output: [], stopReason: 'aborted', @@ -595,7 +618,7 @@ describe('task admission and package contracts', () => { }) claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) expect(ctx.subagents.getProvider('claude-code')).toBeDefined() - const run = await ctx.subagents.start('claude-code', request()) + const run = await startExternalActivation(ctx, 'claude-code', request()) await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'native model answer' }], stopReason: 'completed', @@ -627,7 +650,7 @@ describe('task admission and package contracts', () => { disposeGraceMs: 29, }) - await expect(ctx.subagents.start('claude-diagnostic', { + await expect(startExternalActivation(ctx, 'claude-diagnostic', { ...request(), parent: { id: 'parent-without-cwd', @@ -638,40 +661,13 @@ describe('task admission and package contracts', () => { ) expect(queryMock).not.toHaveBeenCalled() - const invalidCwdParent = { - id: 'parent-with-invalid-cwd', - session: { header: { cwd: 'relative/SECRET_TOKEN' } }, - } as unknown as Agent - const invalidCwd = ctx.subagents.start('claude-diagnostic', { - ...request(), - parent: invalidCwdParent, - }) - await expect(invalidCwd) - .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) - await expect(invalidCwd).rejects.not.toThrow('relative/SECRET_TOKEN') - expect(warn).toHaveBeenCalledWith( - 'subagent-claude-code "claude-diagnostic": child start failed: %o', - expect.any(Error), - ) - expect(errorCause(warn.mock.calls[0]?.[1] as unknown)?.message) - .toContain('relative/SECRET_TOKEN') - - const invalidCwdAbort = new AbortController() - invalidCwdAbort.abort(new Error('cancel invalid cwd startup')) - await expect(ctx.subagents.start('claude-diagnostic', { - ...request(undefined, invalidCwdAbort.signal), - parent: invalidCwdParent, - })).rejects.toThrow('aborted before SDK startup') - expect(queryMock).not.toHaveBeenCalled() - warn.mockClear() - vi.stubEnv('PATH', '/host/bin') queryMock.mockImplementationOnce(() => { throw new Error( 'Native CLI binary for fixture-platform not found. Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set options.pathToClaudeCodeExecutable.', ) }) - const missingPayload = ctx.subagents.start('claude-diagnostic', request()) + const missingPayload = startExternalActivation(ctx, 'claude-diagnostic', request()) await expect(missingPayload) .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) await expect(missingPayload).rejects.not.toThrow('Native CLI binary') @@ -685,7 +681,7 @@ describe('task admission and package contracts', () => { .toContain('Native CLI binary for fixture-platform not found') expect(resolveExecutable).not.toHaveBeenCalled() - const run = await ctx.subagents.start('claude-diagnostic', request()) + const run = await startExternalActivation(ctx, 'claude-diagnostic', request()) child.settle({ exitCode: 9, signal: null }) child.stdout.end() await expect(run.result).resolves.toEqual({ diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index d26c9f95da..94530f1949 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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-codex/README.md -README.md: 3f90c9430c296d1233f1d2973e51ed3517e61dc1 -README.zh.md: 9b122325ea4672f461a5a6def87649fd6ac43e8d +README.md: a222a0290f03a3ab7088114e3241498ba1f83dfc +README.zh.md: f85a332b4a4973edfa863ef988c34bcfc1f52e65 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 3f90c9430c..a222a0290f 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -62,24 +62,19 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Each delegation tool row names one provider and needs its own `toolName`, so the model sees static tools rather than a dynamic provider selector. Full Agent Presets carry a matching default tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. ```yaml -- id: jobs - name: '@deepseek-ai/dsh-jobs-local' -- id: tool-jobs - name: '@deepseek-ai/dsh-tool-jobs' - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: provider: codex toolName: subagent_codex - backgroundMode: one-shot maxDepth: provider-managed ``` -The `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`; the base host and full presets already provide the generic Job registry and controls. +The tool returns a child id after accepting the task and sends its result to the parent Agent on completion. Each external activation executes once and accepts no follow-up input or resume. ### What you get -A foreground call gives the model the selected final Codex answer, or an error with the stop reason and optional safe diagnostic for a failed run. A background call first returns a Job id; the generic job controls later deliver a completion notice and expose the same final answer or failed status through `job_output`. Codex commentary, reasoning, tool activity, raw stderr, and workspace diffs never enter the parent session. +The completion notice contains the final Codex answer, or the stop reason and optional safe diagnostic. The parent Session also retains the external task identity and complete terminal result independently of the notice; no local child Session is created. Product reasoning, tool activity, raw stderr, and workspace diffs do not enter the parent Session. ### Failure and recovery @@ -152,15 +147,15 @@ Independent of the parent request cache. Reuse depends only on Codex's own provi #### What the model sees -Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. The diagnostic can distinguish a coarse action category, protocol stage, applicable numeric HTTP status, and observed process outcome without copying product prose or stderr. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the same final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, raw stderr, workspace diffs, usage, product ids, commands, paths, and protocol payloads are not copied into the parent Session. +Through `dsh-tool-subagent`, the parent model first receives a child id, then the final Codex answer or a failure notice with its stop reason and safe diagnostic. Diagnostics contain only fixed stage, category, and observed protocol or process facts. Product reasoning, intermediate messages, tool activity, stderr, usage, product identifiers, commands, paths, and raw protocol payloads are not copied into the parent Session. #### Token effect -Foreground input grows by the retained final answer or error. Background input also includes the start acknowledgement, completion notice, and any `job_output`, `job_kill`, or later status results; child tokens still do not enter the parent context. This provider adds no parent tool schema by itself. +Parent input grows by the start acknowledgement and completion notice, including the final answer or failure detail. Child tokens do not enter the parent context. This provider adds no parent tool schema by itself. #### KV Cache effect -Append-only: foreground adds one result after the reusable parent prefix, while background appends the Job acknowledgement, notice, and later control or collection results. Background scheduling can add a notice-driven turn, but none of these messages rewrites the earlier prefix. +Start acknowledgements and completion notices append after the reusable parent request prefix. A notice may wake another turn without rewriting the existing prefix. ## Known Limitations and Deferred Work @@ -175,7 +170,7 @@ These limits define when this provider is a poor fit or needs special operationa - **The native platform payload is required at delegation time** — installs that omit optional dependencies, unsupported platforms, and missing or damaged payloads fail at the first run; there is no host-CLI fallback. - **Compatibility is pinned by development evidence** — upgrading from the verified 0.153.4 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. - **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; the three Profile modes never create a DSH interaction channel or per-call allow policy. -- **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, commentary, intermediate messages, tool traffic, usage, raw stderr, and workspace diffs remain outside the parent Session, while generic Job ids, notices, and status come from the shared job runtime. +- **Assistant payload is final text only** — failed runs can also expose a separate safe diagnostic; reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain outside the parent Session. Task identity and terminal results are retained in the parent log. - **No optional shared capabilities** — `agentOptions`, output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 9b122325ea..f85a332b4a 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -62,24 +62,19 @@ dsh --profile 每个委派工具行指名一个提供方,并需要独立的 `toolName`,因此模型看到的是静态工具,而不是动态提供方选择器。完整 Agent Preset 携带对应的默认工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。 ```yaml -- id: jobs - name: '@deepseek-ai/dsh-jobs-local' -- id: tool-jobs - name: '@deepseek-ai/dsh-tool-jobs' - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: provider: codex toolName: subagent_codex - backgroundMode: one-shot maxDepth: provider-managed ``` -`one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job id,供 `job_output` 或 `job_kill` 使用;base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 +工具接受任务后返回 child id;任务完成后向父 agent 发送结果通知。外部 activation 只执行一次,不支持追加输入或恢复对话。 ### 你会得到什么 -前台调用会把选定的最终 Codex 答案交给模型;运行失败时则返回带停止原因与可选安全诊断的错误。后台调用先返回 Job id;随后通用作业控制面会送达完成通知,并通过 `job_output` 公开同一最终答案或失败状态。Codex 的过程说明、推理、工具活动、原始 stderr 与工作区差异绝不会进入父级会话。 +完成通知包含 Codex 的最终答案,或停止原因与可选的安全诊断。父会话还独立保存外部任务的身份和完整终态结果;无需创建本地子 Session。产品推理、工具活动、原始 stderr 与工作区差异不会进入父会话。 ### 失败与恢复 @@ -152,15 +147,15 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 #### 模型看到什么 -通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。该诊断可以区分粗粒度行动类别、协议阶段、适用的数值 HTTP status 和已观测的进程结果,而不复制产品正文或 stderr。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开同一最终答案或失败状态详情,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、原始 stderr、工作区差异、用量信息、产品标识符、命令、路径和协议载荷均不会复制到父会话。 +通过 `dsh-tool-subagent`,父模型先收到 child id,随后收到 Codex 最终答案或带停止原因与安全诊断的失败通知。诊断只包含固定的阶段、类别和已观测的协议或进程事实。产品推理、中间消息、工具活动、stderr、用量、产品标识符、命令、路径与原始协议载荷不会复制到父 Session。 #### Token 影响 -前台输入会增加工具结果中保留的最终答案或错误内容。后台输入还会包含启动确认、完成通知,以及 `job_output`、`job_kill` 或后续状态结果;子任务 token 仍不会进入父级上下文。本提供方自身不添加父级工具 schema。 +父级输入会增加启动确认与完成通知,包括最终答案或失败信息。子任务 token 不进入父级上下文。本提供方自身不添加父级工具 schema。 #### KV Cache 影响 -仅追加:前台会在可复用的父请求前缀后增加一个结果,后台则会继续追加 Job 启动确认、通知以及后续控制或收集结果。后台调度可能增加一个由通知唤醒的轮次,但这些消息都不会改写更早的前缀。 +启动确认与完成通知仅追加到可复用的父请求前缀之后。通知可能唤醒新轮次,不会改写已有前缀。 ## 已知限制与延期工作 @@ -175,7 +170,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 - **委派时必须存在原生平台载荷**——省略 optional dependencies 的安装、不受支持的平台以及缺失或损坏的载荷都会在第一次运行时失败;不会回退到宿主 CLI。 - **兼容性由开发证据锁定**——若要从已验证的 0.153.4 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 - **没有人工审批路径**——已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;三种 Profile 模式都不会创建 DSH 交互通道或逐次调用 allow 策略。 -- **assistant 载荷仅包含最终文本**——失败运行可以额外公开独立的安全诊断;推理、过程说明、中间消息、工具通信、用量信息、原始 stderr 和工作区差异不会进入父会话,通用 Job id、通知与状态来自共享作业运行时。 +- **assistant 载荷仅包含最终文本**——失败运行还可公开独立的安全诊断;推理、中间消息、工具通信、用量、stderr 与工作区差异不会进入父 Session。任务身份与终态结果保存在父日志中。 - **没有可选的共享能力**——对于本提供方,共享服务会拒绝 `agentOptions`、输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 - **没有按实际经过时间触发的超时或副作用回滚**——长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-codex/tests/fixtures/loader/codex.patch.yml b/packages/subagent/subagent-codex/tests/fixtures/loader/codex.patch.yml index e55f0e37cc..aae62dbacd 100644 --- a/packages/subagent/subagent-codex/tests/fixtures/loader/codex.patch.yml +++ b/packages/subagent/subagent-codex/tests/fixtures/loader/codex.patch.yml @@ -27,7 +27,6 @@ config: provider: codex toolName: subagent_codex - backgroundMode: one-shot maxDepth: 'provider-managed' - id: tool-subagent-codex-primary @@ -35,7 +34,6 @@ config: provider: codex-primary toolName: subagent_codex_primary - backgroundMode: one-shot maxDepth: 'provider-managed' - id: tool-subagent-codex-secondary @@ -43,5 +41,4 @@ config: provider: codex-secondary toolName: subagent_codex_secondary - backgroundMode: one-shot maxDepth: 'provider-managed' diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts index 533ce2aa0a..22c02dc87d 100644 --- a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -80,17 +80,17 @@ describe('Codex provider public Loader composition', () => { tools: [ { name: 'subagent_codex', - parameterNames: ['description', 'prompt', 'run_in_background'], + parameterNames: ['description', 'prompt'], required: ['description', 'prompt'], }, { name: 'subagent_codex_primary', - parameterNames: ['description', 'prompt', 'run_in_background'], + parameterNames: ['description', 'prompt'], required: ['description', 'prompt'], }, { name: 'subagent_codex_secondary', - parameterNames: ['description', 'prompt', 'run_in_background'], + parameterNames: ['description', 'prompt'], required: ['description', 'prompt'], }, ], diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts index e72f2d1244..30101e178f 100644 --- a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -1,3 +1,4 @@ +import { startExternalActivation } from '../../subagent/tests/external-activation-helpers.ts' import { execFile } from 'node:child_process' import { randomUUID } from 'node:crypto' import { @@ -118,7 +119,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( id: 'deepseek-e2e-parent', session: { header: { cwd: workspace } }, } as unknown as Agent - const run = await ctx.subagents.start('codex', { + const run = await startExternalActivation(ctx, 'codex', { prompt: [{ type: 'text', text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`, diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 0446ab3446..feb3278a9c 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -1,3 +1,4 @@ +import { startExternalActivation } from '../../subagent/tests/external-activation-helpers.ts' import { execFile } from 'node:child_process' import { cpSync, @@ -242,7 +243,7 @@ describe('real @openai/codex 0.153.4 product', () => { }) expect(schema.definitions.ThreadStartParams.required).toBeUndefined() - const run = await harness.ctx.subagents.start('codex', { + const run = await startExternalActivation(harness.ctx, 'codex', { prompt: [{ type: 'text', text: task }], parent: harness.parent, signal: new AbortController().signal, @@ -320,12 +321,12 @@ describe('real @openai/codex 0.153.4 product', () => { const safeController = new AbortController() const [safeRun, bypassRun] = await Promise.all([ - ctx.subagents.start('codex-safe', { + startExternalActivation(ctx, 'codex-safe', { prompt: [{ type: 'text', text: 'Hold the safe instance.' }], parent: safeParent, signal: safeController.signal, }), - ctx.subagents.start('codex-bypass', { + startExternalActivation(ctx, 'codex-bypass', { prompt: [{ type: 'text', text: 'Complete the bypass instance.' }], parent: bypassParent, signal: new AbortController().signal, @@ -334,7 +335,7 @@ describe('real @openai/codex 0.153.4 product', () => { await safeInstance.fixture.requestStarted await safeFiber.dispose() expect(ctx.subagents.list()).toEqual(['codex-bypass']) - await expect(ctx.subagents.start('codex-safe', { + await expect(startExternalActivation(ctx, 'codex-safe', { prompt: [{ type: 'text', text: 'This start must fail.' }], parent: safeParent, signal: new AbortController().signal, @@ -344,7 +345,7 @@ describe('real @openai/codex 0.153.4 product', () => { output: [{ type: 'text', text: 'NAMED_CODEX_BYPASS_RESULT' }], stopReason: 'completed', }) - safeController.abort(new Error('cancel only the published safe run')) + void safeRun.dispose() await expect(safeRun.result).resolves.toEqual({ output: [], stopReason: 'aborted', @@ -399,7 +400,7 @@ describe('real @openai/codex 0.153.4 product', () => { }, ]) const sideEffect = join(harness.workspace, 'approval-side-effect') - const run = await harness.ctx.subagents.start('codex', { + const run = await startExternalActivation(harness.ctx, 'codex', { prompt: [{ type: 'text', text: 'Attempt the fixture command.' }], parent: harness.parent, signal: new AbortController().signal, @@ -435,7 +436,7 @@ describe('real @openai/codex 0.153.4 product', () => { status: 503, message: 'SECRET_TOKEN in /private/secret.txt', }]) - const run = await harness.ctx.subagents.start('codex', { + const run = await startExternalActivation(harness.ctx, 'codex', { prompt: [{ type: 'text', text: 'Exercise the service failure path.' }], parent: harness.parent, signal: new AbortController().signal, @@ -452,7 +453,7 @@ describe('real @openai/codex 0.153.4 product', () => { } { const { harness, fixture } = await realHarness([{ kind: 'hold' }]) - const run = await harness.ctx.subagents.start('codex', { + const run = await startExternalActivation(harness.ctx, 'codex', { prompt: [{ type: 'text', text: 'Exercise the process failure path.' }], parent: harness.parent, signal: new AbortController().signal, @@ -498,7 +499,7 @@ describe('real @openai/codex 0.153.4 product', () => { ] }, 'dangerously-bypass-approvals-and-sandbox') const target = join(harness.workspace, sideEffect) - const run = await harness.ctx.subagents.start('codex', { + const run = await startExternalActivation(harness.ctx, 'codex', { prompt: [{ type: 'text', text: 'Create the fixture side effect.' }], parent: harness.parent, signal: new AbortController().signal, @@ -516,13 +517,13 @@ describe('real @openai/codex 0.153.4 product', () => { it('settles cancellation locally and leaves the real app-server tree quiescent', async () => { const { harness, fixture } = await realHarness([{ kind: 'hold' }]) const controller = new AbortController() - const run = await harness.ctx.subagents.start('codex', { + const run = await startExternalActivation(harness.ctx, 'codex', { prompt: [{ type: 'text', text: 'Wait for cancellation.' }], parent: harness.parent, signal: controller.signal, }) await fixture.requestStarted - controller.abort(new Error('real product cancellation')) + void run.dispose() await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) await run.dispose() await expectQuiescent(harness.handles) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 860fbc5e9b..b2c524e338 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1,3 +1,4 @@ +import { startExternalActivation } from '../../subagent/tests/external-activation-helpers.ts' import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { PassThrough } from 'node:stream' @@ -8,7 +9,7 @@ import * as yaml from 'js-yaml' import { describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SubagentRuntime, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { @@ -495,11 +496,11 @@ describe('task admission and package contracts', () => { expect(added).toEqual(['codex-safe', 'codex-bypass']) const safeController = new AbortController() - const safeStarting = ctx.subagents.start( + const safeStarting = startExternalActivation(ctx, 'codex-safe', request(undefined, safeController.signal), ) - const bypassStarting = ctx.subagents.start('codex-bypass', request()) + const bypassStarting = startExternalActivation(ctx, 'codex-bypass', request()) for (const [child, model] of [ [safeChild, 'codex-safe-model'], [bypassChild, 'codex-bypass-model'], @@ -520,7 +521,7 @@ describe('task admission and package contracts', () => { await safeFiber.dispose() expect(ctx.subagents.list()).toEqual(['codex-bypass']) expect(removed).toEqual(['codex-safe']) - await expect(ctx.subagents.start('codex-safe', request())) + await expect(startExternalActivation(ctx, 'codex-safe', request())) .rejects.toMatchObject({ code: 'NO_PROVIDER' }) const safeTurn = await safeChild.peer.nextMethod('turn/start') @@ -535,7 +536,7 @@ describe('task admission and package contracts', () => { output: [{ type: 'text', text: 'bypass answer' }], stopReason: 'completed', }) - safeController.abort(new Error('stop only the safe instance')) + void safeRun.dispose() await expect(safeRun.result).resolves.toEqual({ output: [], stopReason: 'aborted', @@ -603,7 +604,7 @@ describe('task admission and package contracts', () => { vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle) codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) expect(ctx.subagents.getProvider('codex')).toBeDefined() - const starting = ctx.subagents.start('codex', request()) + const starting = startExternalActivation(ctx, 'codex', request()) const initialize = await child.peer.nextMethod('initialize') child.peer.respond(initialize, { userAgent: 'codex-cli 0.153.4' }) await child.peer.nextMethod('initialized') @@ -697,7 +698,7 @@ describe('task admission and package contracts', () => { const spawn = vi.spyOn(ctx.subprocess, 'spawn') await ctx.plugin(codex, {}) - await expect(ctx.subagents.start('codex', { + await expect(startExternalActivation(ctx, 'codex', { prompt: [{ type: 'text', text: 'task' }], parent: { id: 'parent-without-cwd', @@ -711,6 +712,28 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) + it.each([false, true])('rejects an invalid parent directory before spawning (cancelled: %s)', async (cancelled) => { + const ctx = new Context() + try { + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + await ctx.plugin(codex, {}) + const spawn = vi.spyOn(ctx.subprocess, 'spawn') + const controller = new AbortController() + if (cancelled) controller.abort() + const provider = ctx.subagents.getProvider('codex')! + await expect(Promise.resolve().then(() => provider.start!({ + ...request(undefined, controller.signal), + parent: { id: 'invalid-cwd-parent', session: { header: { cwd: 'relative-workspace' } } } as unknown as Agent, + descriptor: snapshotSubagentDescriptor({ mode: 'one-shot', provider: provider.name }), + }))).rejects.toThrow(cancelled + ? 'request was aborted before app-server startup' + : 'stage: initialize; category: unknown') + expect(spawn).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } + }) + it('keeps the namespace export shape', () => { expect('default' in codex).toBe(false) expect(codex.name).toBe('subagent-codex') @@ -2135,41 +2158,7 @@ describe('run lifecycle and quiescence', () => { disposeGraceMs: 25, }) - const invalidCwdParent = { - id: 'parent-with-invalid-cwd', - session: { header: { cwd: 'relative/SECRET_TOKEN' } }, - } as unknown as Agent - const invalidCwdError: unknown = await ctx.subagents.start('codex-diagnostic', { - prompt: [{ type: 'text', text: 'task' }], - parent: invalidCwdParent, - signal: new AbortController().signal, - }).then( - () => undefined, - (error: unknown) => error, - ) - expect(invalidCwdError).toBeInstanceOf(Error) - if (!(invalidCwdError instanceof Error)) { - throw new Error('expected safe invalid-cwd failure') - } - expect(invalidCwdError.message).toContain( - expectedFailureDiagnostic('initialize', 'unknown'), - ) - expect(invalidCwdError.message).not.toContain('relative/SECRET_TOKEN') - expect(invalidCwdError.cause).toBeInstanceOf(Error) - expect((invalidCwdError.cause as Error).message) - .toContain('relative/SECRET_TOKEN') - expect(spawn).not.toHaveBeenCalled() - - const invalidCwdAbort = new AbortController() - invalidCwdAbort.abort(new Error('cancel invalid cwd startup')) - await expect(ctx.subagents.start('codex-diagnostic', { - prompt: [{ type: 'text', text: 'task' }], - parent: invalidCwdParent, - signal: invalidCwdAbort.signal, - })).rejects.toThrow('aborted before app-server startup') - expect(spawn).not.toHaveBeenCalled() - - const starting = ctx.subagents.start('codex-diagnostic', { + const starting = startExternalActivation(ctx, 'codex-diagnostic', { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent, signal: new AbortController().signal, diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index 0fc408232f..46b1ea3f02 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/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-dsh-sdk/README.md -README.md: 013fc6eed19c1e7b1849398ca9c002935a92d3bc -README.zh.md: b5d8816faedf05475584cc715114b833f765924e +README.md: aada91620d549cb02188ad058985c95c1a16de10 +README.zh.md: e8cc71cba09f36e40ae98d1836bbf0e1ad015bfb diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index 013fc6eed1..aada91620d 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -31,7 +31,7 @@ Mount this provider when a delegation should run as a complete Harness runtime i Choose this backend when the child must be a full harness peer — its own composition, session persistence, model route, and tools — rather than an agent that shares the parent's process. Choose an in-process backend when the child must share the parent's composition or honor parent-enforced non-route capabilities: this provider accepts agent route options but rejects structured output, depth caps, tool filters, and personas rather than silently omitting them. -The provider advertises `agentOptions: true`, with `outputSchema`/`depthLimit`/`toolFilter`/`persona` false, and `inheritsParentContext: false`. Its immutable `agentRouteDefaults` publish the configured provider/model baseline to `dsh-tool-subagent` before model overrides and exact-route preflight; `start()` independently applies the same configuration defaults for direct callers and `maxTokens`. Agent route values cross the SDK wire as an explicit whitelist; the child remains a fresh runtime in another process, and the only value derived from the parent agent itself is the workspace cwd. `dsh-tool-subagent` deployments over this provider set `maxDepth: 'provider-managed'` — the child harness owns its own recursion budget. +The provider advertises `agentOptions: true`, with `outputSchema`/`depthLimit`/`toolFilter`/`persona` false, and `inheritsParentContext: false`. Its immutable `agentRouteDefaults` publish the configured provider/model baseline to `dsh-tool-subagent` before model overrides and exact-route preflight; the backend applies the same configuration defaults during activation startup, including `maxTokens`. Agent route values cross the SDK wire as an explicit whitelist; the child remains a fresh runtime in another process, and the only value derived from the parent agent itself is the workspace cwd. `dsh-tool-subagent` deployments over this provider set `maxDepth: 'provider-managed'` — the child harness owns its own recursion budget. ### Configuration @@ -104,7 +104,7 @@ This section explains how the backend drives a child Harness runtime and where t ### Run flow -A start resolves the child's working directory and one process-wide SDK route before spawning. Each declared `request.agentOptions` field (`provider`, `model`, `reasoningEffort`, or `maxTokens`) overrides the matching provider-instance default; omission preserves the configured provider/model and optional cap, while reasoning effort remains absent unless the request supplies it. The provider spawns the runtime through the SDK client and completes the `initialize` handshake, including exact-model and effort validation, before it fulfills. A route, spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess is reaped; a working-directory resolution failure rejects before spawning. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete non-empty `assistant/message` (an empty-content message that records usage is skipped), or the accumulated `text-delta` stream when no such message exists. Disposal is idempotent: it settles the result locally as `aborted`, sends a bounded protocol `shutdown` request, then escalates through stdin EOF → SIGTERM → SIGKILL to actual exit. +A start resolves the child's working directory and one process-wide SDK route before spawning. Each declared `request.agentOptions` field (`provider`, `model`, `reasoningEffort`, or `maxTokens`) overrides the matching provider-instance default; omission preserves the configured provider/model and optional cap, while reasoning effort remains absent unless the request supplies it. The provider spawns the runtime through the SDK client and completes the `initialize` handshake, including exact-model and effort validation, before it fulfills. A route, spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess is reaped; a working-directory resolution failure rejects before spawning. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete non-empty `assistant/message` (an empty-content message that records usage is skipped), or the accumulated `text-delta` stream when no such message exists. Disposal is idempotent: it settles the result locally as `aborted`, sends a bounded protocol `shutdown` request, then escalates through stdin EOF → SIGTERM → SIGKILL to actual exit. The activation manager owns that run after acceptance; the caller's startup signal no longer cancels the child. Each activation executes once, and its receipt cancels execution and awaits cleanup. ### Stop-reason mapping @@ -152,11 +152,11 @@ Independent of the parent request cache. Each SDK child can reuse only prefixes #### What the model sees -Through `dsh-tool-subagent`, the parent receives only the child's final assistant text (or accumulated partial text) or that consumer's exact stop-reason error, not intermediate messages or tool traffic. A diagnostic-bearing non-completed result presents the safe diagnostic before separately preserved partial assistant output; startup and shutdown errors expose the same fixed facts without raw SDK text. +Through `dsh-tool-subagent`, the parent model first receives a child id, then a completion notice containing final or partial assistant text, the stop reason, and safe diagnostic. The parent Session independently retains the external task identity and complete terminal result. Intermediate messages and tool traffic do not enter the parent Session. #### Token effect -Parent input grows only by the final result or error, which is data-dependent and retained until compaction. This provider adds no parent schema itself. +Parent input grows by the start acknowledgement and completion notice, whose size depends on the result data and persists until compaction. This provider adds no parent schema by itself. #### KV Cache effect @@ -171,7 +171,7 @@ These limits define when this backend is a poor fit or needs special operational - **A fresh runtime process per run** — no pooling; a harness runtime boots a full plugin tree, so per-run spawn cost is higher than the ACP backend's typical child. - **No non-route start-time capabilities** — the parent can select the child agent route but cannot enforce `outputSchema`, depth, tool filters, or persona inside the child process; configure the selected child profile and its ordered patches instead. -- **The child's transcript stays in the child's own session root** — the parent log records only the delegation tool call and result; the streamed `session.event` channel is consumed for output extraction, not bridged into the parent log. +- **The child's transcript stays in the child's own session root** — the parent log retains the external task identity and full result, while the streamed `session.event` channel is consumed for output extraction rather than transcript replication. - **Local child processes only** — the resolved working directory is a local path; a remote runtime would need its own backend. diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index b5d8816fae..e8cc71cba0 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 当子进程必须是完整的 harness 对等体——拥有自己的组合、会话持久化、模型路由与工具——而不是共享父进程的 agent 时,选择此后端。当子进程必须共享父级组合或遵守父级强制的非路由能力时,请选择进程内后端:本提供方接受 agent 路由选项,但会拒绝结构化输出、深度上限、工具过滤或 persona,而不是静默省略。 -提供方声明 `agentOptions: true`,同时保持 `outputSchema`/`depthLimit`/`toolFilter`/`persona` 为 false,并且 `inheritsParentContext: false`。不可变的 `agentRouteDefaults` 会在模型覆盖与确切路由预检前,把配置的 provider/model 基线公开给 `dsh-tool-subagent`;`start()` 则为直接调用方独立应用同一份配置默认值,包括 `maxTokens`。agent 路由值通过显式白名单跨越 SDK 协议;子进程仍是另一进程里的全新运行时,唯一从父 agent 本身派生的值是工作区 cwd。基于本提供方的 `dsh-tool-subagent` 部署应设置 `maxDepth: 'provider-managed'`——子 harness 拥有自己的递归预算。 +提供方声明 `agentOptions: true`,同时保持 `outputSchema`/`depthLimit`/`toolFilter`/`persona` 为 false,并且 `inheritsParentContext: false`。不可变的 `agentRouteDefaults` 会在模型覆盖与确切路由预检前,把配置的 provider/model 基线公开给 `dsh-tool-subagent`;后端在 activation 启动时应用同一份配置默认值,包括 `maxTokens`。agent 路由值通过显式白名单跨越 SDK 协议;子进程仍是另一进程里的全新运行时,唯一从父 agent 本身派生的值是工作区 cwd。基于本提供方的 `dsh-tool-subagent` 部署应设置 `maxDepth: 'provider-managed'`——子 harness 拥有自己的递归预算。 ### 配置 @@ -104,7 +104,7 @@ kind: "package-reference" ### 运行流程 -一次启动会在 spawn 前解析子进程工作目录与一条进程级 SDK 路由。`request.agentOptions` 中每个已声明字段(`provider`、`model`、`reasoningEffort` 或 `maxTokens`)都会覆盖对应的提供方实例默认值;省略时保留已配置的提供方/模型与可选上限,而推理强度只有在请求提供时才会出现。随后,提供方通过 SDK 客户端 spawn 运行时,并在履行前完成 `initialize` 握手,其中包括确切模型与推理强度校验。路由、spawn、握手或发布前取消失败时,只会在子进程被回收后拒绝;工作目录解析失败则会在尚未 spawn 任何内容时拒绝。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空的 `assistant/message`(记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta` 流。dispose 是幂等的:先在本地把结果确定为 `aborted`,发出有界的协议 `shutdown` 请求,再经 stdin EOF → SIGTERM → SIGKILL 升级到实际退出。 +一次启动会在 spawn 前解析子进程工作目录与一条进程级 SDK 路由。`request.agentOptions` 中每个已声明字段(`provider`、`model`、`reasoningEffort` 或 `maxTokens`)都会覆盖对应的提供方实例默认值;省略时保留已配置的提供方/模型与可选上限,而推理强度只有在请求提供时才会出现。随后,提供方通过 SDK 客户端 spawn 运行时,并在履行前完成 `initialize` 握手,其中包括确切模型与推理强度校验。路由、spawn、握手或发布前取消失败时,只会在子进程被回收后拒绝;工作目录解析失败则会在尚未 spawn 任何内容时拒绝。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空的 `assistant/message`(记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta` 流。dispose 是幂等的:先在本地把结果确定为 `aborted`,发出有界的协议 `shutdown` 请求,再经 stdin EOF → SIGTERM → SIGKILL 升级到实际退出。任务接受后由 activation 管理器拥有该运行;此后调用方的启动信号不再取消子任务。每个 activation 只执行一次,其凭据用于取消执行并等待清理。 ### 停止原因映射 @@ -152,11 +152,11 @@ kind: "package-reference" #### 模型看到什么 -经由 `dsh-tool-subagent`,父级只会收到子运行时最终的 assistant 文本(或累积的部分文本),或该消费方给出的精确停止原因错误;不会收到中间消息或工具流量。带诊断的非完成结果会先呈现安全诊断,再单独呈现保留的部分 assistant 输出;启动与 shutdown 错误使用同一固定事实,不公开原始 SDK 文本。 +通过 `dsh-tool-subagent`,父模型先收到 child id,随后收到含最终或部分 assistant 文本、停止原因与安全诊断的完成通知。父会话独立保存外部任务身份和完整终态结果。中间消息与工具通信不进入父会话。 #### Token 影响 -父级输入只增加最终结果或错误,其大小取决于数据,并保留到压缩(compaction)为止。本提供方自身不会向父级添加任何 schema。 +父级输入增加启动确认与完成通知,内容大小取决于数据,并保留到压缩(compaction)为止。本提供方自身不会向父级添加任何 schema。 #### KV Cache 影响 @@ -171,7 +171,7 @@ kind: "package-reference" - **每次运行都使用全新的运行时进程**——不使用进程池;harness 运行时需要启动完整的插件树,因此每次运行的 spawn 成本高于 ACP 后端通常使用的子进程。 - **不支持路由之外的启动时能力**——父级可以选择子 agent 路由,但无法在子进程内强制执行 `outputSchema`、深度限制、工具过滤或 persona;应改为配置所选子 profile 及其有序 patch。 -- **子进程的 transcript(文本记录)保留在其自身的会话根目录中**——父级日志只记录委派工具调用与结果;流式 `session.event` 通道只用于提取输出,不会桥接到父级日志中。 +- **子任务转录保留在子进程自己的会话根目录中**——父日志保留外部任务身份与完整结果;流式 `session.event` 通道用于提取输出,不用于复制子任务转录。 - **仅支持本地子进程**——解析出的工作目录是本地路径;远程运行时需要独立的后端。 diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts index 738fd7b57e..cd30075474 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts @@ -20,10 +20,6 @@ export function apply(ctx: Context, config: Config): void { provider: config.provider, modelSelectionSettings: true, ...(config.toolName === undefined ? {} : { toolName: config.toolName }), - ...(config.enableRunInBackground === undefined - ? {} - : { enableRunInBackground: config.enableRunInBackground }), - ...(config.backgroundMode === undefined ? {} : { backgroundMode: config.backgroundMode }), ...(config.agentOptions === undefined ? {} : { agentOptions: config.agentOptions }), ...(config.persona === undefined ? {} : { persona: config.persona }), ...(config.toolFilter === undefined ? {} : { toolFilter: config.toolFilter }), diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index ba26c0c69c..d77ceb05cc 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -6,13 +6,14 @@ * quiescent disposal are all exercised end to end. No model, no key. */ +import { startExternalActivation } from '../../subagent/tests/external-activation-helpers.ts' import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' -import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SubagentRuntime, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import { @@ -161,8 +162,8 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('runs a child turn end to end with a parent-unique run id', async () => { const ctx = await setup({ FAKE_TEXT: 'hello from sdk child' }) - const run = await ctx.subagents.start('dsh-sdk', request('do X')) - expect(run.localAgent).toBeUndefined() + const run = await startExternalActivation(ctx, 'dsh-sdk', request('do X')) + expect(ctx.sessions.get(run.childId)).toBeUndefined() const result = await run.result expect(result.stopReason).toBe('completed') expect(result.diagnostic).toBeUndefined() @@ -172,8 +173,8 @@ describe('dsh-subagent-dsh-sdk provider', () => { expect(run.dispose()).toBe(disposal) await disposal - const nextRun = await ctx.subagents.start('dsh-sdk', request('again')) - expect(nextRun.id).not.toBe(run.id) + const nextRun = await startExternalActivation(ctx, 'dsh-sdk', request('again')) + expect(nextRun.childId).not.toBe(run.childId) await nextRun.result await nextRun.dispose() await ctx.fiber.dispose() @@ -184,7 +185,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { dshBin: relative(process.cwd(), fakeRuntime), patches: [relative(process.cwd(), existingPatch)], }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) expect(text((await run.result).output)).toBe('explicit dsh child') expect(createdHarnessOptions[0]).toMatchObject({ dshBin: fakeRuntime, @@ -199,7 +200,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { const recordFile = join(tmp, 'init.jsonl') try { const ctx = await setup({ FAKE_RECORD_INIT: recordFile }, { maxTokens: 4096 }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) await run.result await run.dispose() const { readFileSync } = await import('node:fs') @@ -221,7 +222,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { const recordFile = join(tmp, 'init.jsonl') try { const ctx = await setup({ FAKE_RECORD_INIT: recordFile }, { maxTokens: 4096 }) - const run = await ctx.subagents.start('dsh-sdk', request('partial', new AbortController().signal, { + const run = await startExternalActivation(ctx, 'dsh-sdk', request('partial', new AbortController().signal, { reasoningEffort: ReasoningEffortId('high'), })) await run.result @@ -246,13 +247,13 @@ describe('dsh-subagent-dsh-sdk provider', () => { try { const ctx = await setup({ FAKE_RECORD_INIT: recordFile }, { maxTokens: 4096 }) const runs = await Promise.all([ - ctx.subagents.start('dsh-sdk', request('first', new AbortController().signal, { + startExternalActivation(ctx, 'dsh-sdk', request('first', new AbortController().signal, { provider: 'provider-a', model: 'model-a', reasoningEffort: ReasoningEffortId('high'), maxTokens: 111, })), - ctx.subagents.start('dsh-sdk', request('second', new AbortController().signal, { + startExternalActivation(ctx, 'dsh-sdk', request('second', new AbortController().signal, { provider: 'provider-b', model: 'model-b', reasoningEffort: ReasoningEffortId('max'), @@ -295,7 +296,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { DEEPSEEK_API_KEY: 'explicit-child-key', FAKE_TEXT: 'done', }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result const answer = text(result.output) expect(answer).toContain('DSH_TEST_AMBIENT_SECRET_KEY=\n') @@ -309,7 +310,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('maps a max-tokens child turn end', async () => { const ctx = await setup({ FAKE_REASON_KIND: 'max-tokens', FAKE_STATUS: 'error' }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('max-tokens') expect(result.diagnostic).toBeUndefined() @@ -319,7 +320,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('flattens a child turn error into stopReason error and keeps partial text', async () => { const ctx = await setup({ FAKE_REASON_KIND: 'error', FAKE_STATUS: 'error', FAKE_TEXT: 'partial answer' }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('error') expect(result.diagnostic).toBe( @@ -332,7 +333,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('keeps durable attempt text when a malformed final message prevents completion', async () => { const ctx = await setup({ FAKE_MALFORMED_MESSAGE: '1', FAKE_TEXT: 'stream-only answer' }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('error') @@ -343,7 +344,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('classifies a malformed child turn reason as a protocol failure', async () => { const ctx = await setup({ FAKE_MALFORMED_REASON: '1', FAKE_TEXT: 'partial before bad reason' }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result expect(result).toEqual({ @@ -360,7 +361,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { // max-tokens attempt commits only an empty usage anchor. The empty message // is not Assistant output and must not erase the durable attempt fallback. const ctx = await setup({ FAKE_EMPTY_MESSAGE: '1', FAKE_REASON_KIND: 'max-tokens' }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('max-tokens') expect(text(result.output)).toBe('hello from fake runtime') @@ -370,7 +371,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('reports a settled-without-turn child as an error', async () => { const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) expect(await run.result).toMatchObject({ stopReason: 'error', diagnostic: expectedFailure('stage: session-run; category: missing-terminal'), @@ -381,7 +382,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('maps a blocked child turn to the shared refusal stop reason', async () => { const ctx = await setup({ FAKE_REASON_KIND: 'blocked' }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('refusal') expect(result.diagnostic).toBeUndefined() @@ -398,7 +399,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { }) try { const ctx = await setup({ FAKE_MALFORMED: '1' }) - const error = await ctx.subagents.start('dsh-sdk', request()).catch((cause: unknown) => cause) + const error = await startExternalActivation(ctx, 'dsh-sdk', request()).catch((cause: unknown) => cause) expect(error).toBeInstanceOf(AggregateError) expect((error as Error).message).toBe( `subagent-dsh-sdk: ${expectedFailure('stage: initialize; category: protocol')}; ` @@ -476,7 +477,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('preserves a disposed child cancellation without treating it as local cancellation', async () => { const ctx = await setup({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: 'disposed' }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('aborted') expect(result.diagnostic).toBe( @@ -488,7 +489,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('keeps an ordinary child abort diagnostic-free', async () => { const ctx = await setup({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: 'user' }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('aborted') expect(result.diagnostic).toBeUndefined() @@ -499,7 +500,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('uses a fixed fallback for an unknown child terminal reason', async () => { const rawReason = 'private/path/SECRET_TOKEN' const ctx = await setup({ FAKE_REASON_KIND: rawReason }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('error') expect(result.diagnostic).toBe( @@ -510,11 +511,11 @@ describe('dsh-subagent-dsh-sdk provider', () => { await ctx.fiber.dispose() }) - it('aborting the required signal settles a hung child as aborted', async () => { + it('disposing the activation settles a hung child as aborted', async () => { const ctx = await setup({ FAKE_HANG_PROMPT: '1' }, { disposeEofGraceMs: 200, disposeGraceMs: 200 }) const controller = new AbortController() - const run = await ctx.subagents.start('dsh-sdk', request('p', controller.signal)) - controller.abort('test') + const run = await startExternalActivation(ctx, 'dsh-sdk', request('p', controller.signal)) + void run.dispose() const result = await run.result expect(result.stopReason).toBe('aborted') expect(result.diagnostic).toBeUndefined() @@ -576,7 +577,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { // needed to establish this run's durable inbox receipt. The text therefore // lies outside an owned activity interval and cannot become its output. const ctx = await setup({ FAKE_STREAM_THEN_MALFORMED: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('error') expect(result.diagnostic).toBe( @@ -594,7 +595,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { FAKE_TEXT: 'partial before transport exit', FAKE_STDERR: stderr, }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result expect(result.stopReason).toBe('error') expect(result.output).toEqual([{ type: 'text', text: 'partial before transport exit' }]) @@ -612,7 +613,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { .mockRejectedValue(new Error(rawMessage)) try { const ctx = await setup() - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result expect(result.diagnostic).toBe( expectedFailure('stage: session-run; category: unknown'), @@ -651,7 +652,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('dispose cancels a hung child locally and reaps it', async () => { const ctx = await setup({ FAKE_HANG_PROMPT: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) await run.dispose() expect((await run.result).stopReason).toBe('aborted') await ctx.fiber.dispose() @@ -684,24 +685,26 @@ describe('dsh-subagent-dsh-sdk provider', () => { } }) - it('rejects a pre-aborted request through the registered provider before cwd resolution', async () => { + it('rejects a pre-aborted provider request before resolving the parent directory', async () => { const ctx = await setup() const controller = new AbortController() controller.abort() const parent = { id: 'parent', session: { header: {} } } as unknown as Agent - await expect(ctx.subagents.start('dsh-sdk', { + const provider = ctx.subagents.getProvider('dsh-sdk')! + await expect(Promise.resolve().then(() => provider.start!({ label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: controller.signal, - })).rejects.toThrow('subagent request was aborted before the SDK child started') + descriptor: snapshotSubagentDescriptor({ mode: 'one-shot', provider: provider.name }), + }))).rejects.toThrow('aborted before the SDK child started') await ctx.fiber.dispose() }) it('rejects after reaping when the child dies before the handshake', async () => { const rawStderr = 'scripted boot failure at /private/path SECRET_TOKEN' const ctx = await setup({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: rawStderr }) - const failure = await ctx.subagents.start('dsh-sdk', request()).then( + const failure = await startExternalActivation(ctx, 'dsh-sdk', request()).then( () => { throw new Error('start unexpectedly succeeded') }, (error: unknown) => error, ) @@ -717,7 +720,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { [{ FAKE_INIT_ERROR: '1' }, 'protocol'], ] as const)('rejects an initialize failure with safe %s facts', async (env, category) => { const ctx = await setup({ ...env }) - await expect(ctx.subagents.start('dsh-sdk', request())).rejects.toThrow( + await expect(startExternalActivation(ctx, 'dsh-sdk', request())).rejects.toThrow( `subagent-dsh-sdk: ${expectedFailure(`stage: initialize; category: ${category}`)}`, ) await ctx.fiber.dispose() @@ -775,7 +778,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { const ctx = await setup({ FAKE_MALFORMED_PROMPT: '1' }) const warnings: string[] = [] ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) expect(await run.result).toMatchObject({ stopReason: 'error', diagnostic: expectedFailure('stage: session-run; category: protocol'), @@ -789,7 +792,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('wraps a shutdown rejection with safe facts after the runtime is reaped', async () => { const rawCleanup = 'shutdown failed at /private/path SECRET_TOKEN' const ctx = await setup() - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) await run.result const spy = vi.spyOn(DeepSeekHarness.prototype, 'close').mockImplementation(async function (this: DeepSeekHarness) { spy.mockRestore() @@ -800,7 +803,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { const error = await run.dispose().catch((cause: unknown) => cause) expect(error).toBeInstanceOf(Error) expect((error as Error).message).toBe( - `subagent-dsh-sdk: ${expectedFailure('stage: shutdown; category: unknown')}`, + `subagent "${run.childId}" activation handle disposal failed: subagent-dsh-sdk: ${expectedFailure('stage: shutdown; category: unknown')}`, ) expect((error as Error).message).not.toContain(rawCleanup) } finally { @@ -946,7 +949,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { const tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-cwd-')) try { const ctx = await setup({ FAKE_ECHO_CWD: '1', FAKE_TEXT: 'done' }, { cwd: tmp }) - const run = await ctx.subagents.start('dsh-sdk', request()) + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) const result = await run.result const { realpathSync } = await import('node:fs') expect(text(result.output)).toContain(`cwd=${realpathSync(tmp)}`) @@ -960,7 +963,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('fails loud when neither config cwd nor parent session cwd exists', async () => { const ctx = await setup() const parent = { id: 'parent', session: { header: {} } } as unknown as Agent - await expect(ctx.subagents.start('dsh-sdk', { + await expect(startExternalActivation(ctx, 'dsh-sdk', { label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal, })) .rejects.toThrow( diff --git a/packages/subagent/subagent-fork-in-process/README.i18n.yaml b/packages/subagent/subagent-fork-in-process/README.i18n.yaml index 692666ac68..d8de047d0f 100644 --- a/packages/subagent/subagent-fork-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-fork-in-process/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-in-process/README.md -README.md: 6796e1e79c45fa9c282219c2e9c2e0eeb9b38d8f -README.zh.md: 0359ee2083bd2d79a99c09776401cc169f311151 +README.md: 5566ff504e6a6ac3ebf28c7f82af0ac1d4a1f3e6 +README.zh.md: 1bd251c560c21bca6778882c500317290c5267a8 diff --git a/packages/subagent/subagent-fork-in-process/README.md b/packages/subagent/subagent-fork-in-process/README.md index 6796e1e79c..5566ff504e 100644 --- a/packages/subagent/subagent-fork-in-process/README.md +++ b/packages/subagent/subagent-fork-in-process/README.md @@ -55,7 +55,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### What a fork delegation does -One tool call starts one child seeded with the completed turns and waits for its result: the child sees the conversation up to the parent's last completed turn, works in its own session, and the parent receives only its final output — or an errored tool result for cancellation, refusal, token-limit truncation, or startup rejection. The seed is captured once at start; later parent turns never reach the child. +One tool call creates a background child and returns its session id immediately. The child works in its own session; the parent receives a status notice when it settles; the child reports its results through `send_message`. A rejected start leaves no published child. Programmatic callers such as workflows can await the activation result directly. ----- @@ -69,7 +69,7 @@ This section explains the design decisions behind the backend and where the beha ### Design concept -One difference from spawn, expressed as data: the backend computes the balanced completed-turn prefix of the parent's log and hands it to the shared in-process driver as the child's session seed. Because live sequence numbers equal array indexes, the prefix stays a valid seed beginning at sequence zero, and the driver records its length so the result reader never mistakes a seeded parent message for child output. +This backend supplies the balanced completed-turn prefix of the parent log as the child session seed. The subagent service records the inherited range so results contain only the child’s own output. ### Source map @@ -80,11 +80,11 @@ One difference from spawn, expressed as data: the backend computes the balanced ### Run flow -On `start`, the prefix is sliced from the parent's event log up to and including the last `turn/end`; the shared driver then creates the child with that seed, applies the same persona, tool-filter, and structured-output setup, drives one task, reads the child's own final output, and disposes quiescently. The provider advertises `agentOptions` plus the same output, depth, filter, and persona capabilities as spawn. `prepareContinuable` captures the prefix once, at creation, because it becomes part of the child's own durable transcript. +`prepareContinuable()` captures the parent’s completed-turn prefix once at creation. `startActivation()` creates the child with that seed, and the subagent service applies persona, tool filtering, and structured output for this activation. The provider advertises the same agent options, output, depth, filter, and persona capabilities as spawn. ### Lifecycle binding -The base bundle and full CLI presets bind this provider to `backgroundMode: continuable`; explicit foreground calls and one-shot compositions remain supported. Both preserve the inherited request prefix: parent and child receive the same messaging tool definition and ordering, and the continuable child's parent id and return guidance live in its initial user task after inherited history ([cache-preserving fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)). +The delegation tool creates a background activation and returns a stable child session id. Parent and child share the same messaging tool definitions and ordering; the parent id and return guidance follow inherited history in the initial task. Workflows await results with caller delivery and send no completion notice to the parent. @@ -96,7 +96,6 @@ The base bundle and full CLI presets bind this provider to `backgroundMode: cont Read these pages when the package-level contract is not enough; they move from the shared subagent model to the sibling backends and the design evidence for preserving the inherited request prefix. - [Subagent subsystem](../../../docs/subsystems/subagent.md) — start requests, results, provider contract, and in-process depth and seed. -- [dsh-subagent-in-process-driver](../subagent-in-process-driver/README.md) — the shared run driver this backend calls. - [dsh-subagent-spawn-in-process](../subagent-spawn-in-process/README.md) — the fresh-child sibling backend. - [dsh-tool-subagent](../tool-subagent/README.md) — the model-facing delegation tool that reaches this provider. - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-subagent-fork-in-process) — every accepted config field and its source declaration. @@ -125,7 +124,7 @@ The child may reuse the inherited byte-identical prefix under the same provider #### What the model sees -The parent receives only the child's own final output through `dsh-tool-subagent`, not the inherited prefix or intermediate work. +Through `dsh-tool-subagent`, the parent first receives the child session id and later a completion status notice; the child reports its results through `send_message`; the inherited prefix and internal work remain in the child session. #### Token effect @@ -143,7 +142,7 @@ Append-only; newly visible content follows the reusable request prefix and does These limits define when the backend is the wrong choice; they are current package constraints. - **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. -- **Prefix reuse depends on matching request inputs** — parent and child messaging definitions match byte for byte in both lifecycle modes; explicit persona, tool filtering, generated-SDK, or route changes can still break equality. Rationale: the [cache-preserving fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md). +- **Prefix reuse depends on matching request inputs** — parent and child messaging definitions match byte for byte; explicit persona, tool filtering, generated-SDK, or route changes can still break equality. Rationale: the [cache-preserving fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md). - **Shipped fork tools do not expose child LLM route selection** — they inherit the parent's provider and model so the copied history remains eligible for KV Cache reuse. Route selection stays disabled until a change can preserve reuse or expose a bounded recomputation cost; the [model-selected route Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md) owns that restriction. diff --git a/packages/subagent/subagent-fork-in-process/README.zh.md b/packages/subagent/subagent-fork-in-process/README.zh.md index 0359ee2083..1bd251c560 100644 --- a/packages/subagent/subagent-fork-in-process/README.zh.md +++ b/packages/subagent/subagent-fork-in-process/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" ### 一次 fork 委派会做什么 -一次工具调用启动一个以已完成轮次为初始内容的子 agent,并等待其结果:子 agent 能看到截至父级最后一个已完成轮次的对话,在自有会话中工作,父级只接收其最终输出——取消、拒绝、token 上限截断或启动被拒时则收到出错的工具结果。初始内容在启动时只捕获一次;此后的父级轮次绝不会到达子 agent。 +一次工具调用创建后台子 agent 并立即返回子会话 ID。子级在自己的会话中工作,完成后父级收到状态通知,子级通过 `send_message` 汇报结果。被拒绝的启动不会留下已发布的子 agent;工作流等程序调用方可以直接等待 activation 的结果。 ----- @@ -69,7 +69,7 @@ kind: "package-reference" ### 设计理念 -与 spawn 的差异只有一处,且以数据表达:后端计算父级日志的已配平已完成轮次前缀,并把它作为子 agent 的会话初始内容交给共享进程内驱动器。由于实际序号等于数组下标,前缀始终是自序号零开始的合法初始内容;驱动器记录其长度,使结果读取器不会把作为初始内容的父级消息误认为子 agent 输出。 +本后端把父级日志中已配平的已完成轮次前缀作为子会话初始内容。subagent 服务记录继承范围,因此结果只包含子级自己的输出。 ### 源码地图 @@ -80,11 +80,11 @@ kind: "package-reference" ### 运行流程 -`start` 时,从父级事件日志中截取截至最后一个 `turn/end` 的前缀;共享驱动器随后以该初始内容创建子 agent,应用相同的 persona、工具过滤器与结构化输出设置,驱动一项任务,读取子 agent 自身的最终输出,并执行 dispose(资源释放)以等待所有工作完全停稳。该提供方声明 `agentOptions`,以及与 spawn 相同的输出、深度、过滤与 persona 能力。`prepareContinuable` 在创建时只捕获一次前缀,因为该前缀会成为子 agent 自身持久保存的 transcript(文本记录)的一部分。 +`prepareContinuable()` 在创建时捕获一次父级已完成轮次前缀。`startActivation()` 以此创建子 agent,并由 subagent 服务应用 persona、工具过滤与本次 activation 的结构化输出设置。提供方声明与 spawn 相同的 `agentOptions`、输出、深度、过滤及 persona 能力。 ### 生命周期绑定 -base 组合包与完整 CLI(命令行界面)预设在委派工具上把本提供方绑定为 `backgroundMode: continuable`;仍支持显式前台调用与一次性组合。两者都保留继承的请求前缀:父级与子级获得定义和顺序相同的消息工具,可继续子级的父级 ID 与返回指导位于继承历史之后的初始用户任务中(见[保持 fork 缓存的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md))。 +委派工具以后台 activation 创建子级,并返回稳定的子会话 ID。父级与子级共享相同的消息工具定义及顺序;父级 ID 与返回指导位于继承历史之后的初始任务中。工作流以调用方收集模式等待结果,不向父级发送完成通知。 @@ -96,7 +96,6 @@ base 组合包与完整 CLI(命令行界面)预设在委派工具上把本 当包级约定不够用时阅读以下页面;它们从共享 subagent 模型进入兄弟后端,以及保留继承请求前缀的设计证据。 - [Subagent 子系统](../../../docs/subsystems/subagent.zh.md)——启动请求、结果、提供方约定与进程内深度和初始内容。 -- [dsh-subagent-in-process-driver](../subagent-in-process-driver/README.zh.md)——本后端调用的共享运行驱动器。 - [dsh-subagent-spawn-in-process](../subagent-spawn-in-process/README.zh.md)——全新子级的兄弟后端。 - [dsh-tool-subagent](../tool-subagent/README.zh.md)——指向该提供方的面向模型委派工具。 - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-subagent-fork-in-process)——每个受支持配置字段及其源声明。 @@ -125,7 +124,7 @@ fork 会把保留的已完成历史复制到子 agent 的请求中,子 agent #### 模型看到什么 -父级只通过 `dsh-tool-subagent` 接收子 agent 自身的最终输出,不接收继承的前缀或中间工作。 +父级通过 `dsh-tool-subagent` 先收到子会话 ID,随后收到完成状态通知;子级通过 `send_message` 汇报结果;继承的前缀与内部工作保留在子会话中。 #### Token 影响 @@ -143,7 +142,7 @@ fork 会把保留的已完成历史复制到子 agent 的请求中,子 agent 这些限制说明何时选择该后端是错误的;它们是当前包约束。 - **初始内容是一次性快照**——子 agent 只能看到 fork 时父级已完成的轮次,看不到父级此后记录的任何内容;不会实时共享上下文。 -- **前缀复用取决于请求输入是否匹配**——两种生命周期模式下,父级与子级的消息定义都逐字节相同;显式 persona、工具过滤、生成 SDK 或路由变化仍可破坏相等性。理由见[保持 fork 缓存的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md)。 +- **前缀复用取决于请求输入是否匹配**——父级与子级的消息定义都逐字节相同;显式 persona、工具过滤、生成 SDK 或路由变化仍可破坏相等性。理由见[保持 fork 缓存的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md)。 - **随附 fork 工具不公开子级 LLM(大语言模型)路由选择**——它们继承父级提供方与模型,使复制的历史仍有资格复用 KV Cache。在某项改动能保留复用或公开有界重算成本前,路由选择保持禁用;[模型选择路由 Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md)说明这项限制。 diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index 1d054f12a3..92d2292c00 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -30,7 +30,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { @@ -45,7 +44,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^" diff --git a/packages/subagent/subagent-fork-in-process/src/index.ts b/packages/subagent/subagent-fork-in-process/src/index.ts index 0e69915b60..89d3e48239 100644 --- a/packages/subagent/subagent-fork-in-process/src/index.ts +++ b/packages/subagent/subagent-fork-in-process/src/index.ts @@ -14,11 +14,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContinuableCreateRequest, ContinuableCreateSpec, - ResolvedSubagentStartRequest, SubagentCapabilities, SubagentProvider, } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from '@deepseek-ai/dsh-subagent-in-process-driver' export const name = 'subagent-fork-in-process' // `tools` is deliberately NOT injected — same rationale as subagent-spawn-in-process: the @@ -73,15 +71,6 @@ class ForkInProcessProvider implements SubagentProvider { constructor(readonly name: string) {} - start(request: ResolvedSubagentStartRequest) { - const seed = completedTurnPrefix(request.parent) - return startInProcessRun(request, { - // Only pass a seed when there's a completed turn to inherit; an empty seed - // is equivalent to a fresh child, so omit it to keep the session unseeded. - ...seed.length > 0 ? { seed } : {}, - }) - } - 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 diff --git a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts index 35c8af4df3..b3ad0b07fb 100644 --- a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts @@ -1,3 +1,4 @@ +import { mountLocalActivations, startTestActivation as start } from '../../subagent/tests/local-activation.ts' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -8,7 +9,7 @@ import InvariantRegistry 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 SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as fork from '../src/index.ts' @@ -22,9 +23,7 @@ async function mountInvariants(ctx: Context): Promise { await ctx.plugin(AgentLoopInvariant) } -function start(ctx: Context, provider: string, request: Omit & { signal?: AbortSignal }) { - return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request }) -} + /** * The two in-process backends coexist on one context: the SAME parent agent @@ -36,6 +35,7 @@ async function setup(script: Script) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) + await mountLocalActivations(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(Spawn, { providerName: 'spawn' }) @@ -82,8 +82,8 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { expect(text(forkResult.output)).toBe('fork child reply') // The two children are distinct sessions, both lineage-stamped to the parent. - const spawnChild = ctx.agents.get(spawnRun.id)! - const forkChild = ctx.agents.get(forkRun.id)! + const spawnChild = spawnRun.localAgent + const forkChild = forkRun.localAgent expect(spawnChild.session.header.id).not.toBe(forkChild.session.header.id) expect(spawnChild.session.header.parentSession).toBe(parent.session.header.id) expect(forkChild.session.header.parentSession).toBe(parent.session.header.id) diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts index 6f7080cd31..9a6a9e9ad4 100644 --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts @@ -1,3 +1,4 @@ +import { mountLocalActivations, startTestActivation as start } from '../../subagent/tests/local-activation.ts' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -10,12 +11,12 @@ import InvariantRegistry 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 SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import * as fork from '../src/index.ts' -import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver' +import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent' type Script = ConstructorParameters[0] @@ -26,9 +27,7 @@ async function mountInvariants(ctx: Context): Promise { await ctx.plugin(AgentLoopInvariant) } -function start(ctx: Context, provider: string, request: Omit & { signal?: AbortSignal }) { - return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request }) -} + /** A bare `stop` finish that streams no content → the turn ends `completed` * with NO `assistant/message` of its own. */ @@ -44,6 +43,7 @@ async function setup(script: Script) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) + await mountLocalActivations(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(fork, { providerName: 'fork' }) diff --git a/packages/subagent/subagent-fork-in-process/tsconfig.json b/packages/subagent/subagent-fork-in-process/tsconfig.json index 892c4377ef..b7366bc033 100644 --- a/packages/subagent/subagent-fork-in-process/tsconfig.json +++ b/packages/subagent/subagent-fork-in-process/tsconfig.json @@ -25,9 +25,6 @@ }, { "path": "../subagent" - }, - { - "path": "../subagent-in-process-driver" } ] } diff --git a/packages/subagent/subagent-in-process-driver/README.i18n.yaml b/packages/subagent/subagent-in-process-driver/README.i18n.yaml deleted file mode 100644 index d90925e085..0000000000 --- a/packages/subagent/subagent-in-process-driver/README.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 packages/subagent/subagent-in-process-driver/README.md -README.md: fe9703a2bc735cfea81dbf921488edb786335f6f -README.zh.md: 5ee7447829985b71d7f2004bfaa94a98fc187ea1 diff --git a/packages/subagent/subagent-in-process-driver/README.md b/packages/subagent/subagent-in-process-driver/README.md deleted file mode 100644 index fe9703a2bc..0000000000 --- a/packages/subagent/subagent-in-process-driver/README.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -description: "Shared in-process subagent run driver for maintainers and backend authors who need to understand or extend the spawn and fork run lifecycle." -kind: "package-library" ---- - -# @deepseek-ai/dsh-subagent-in-process-driver - -English | [中文](README.zh.md) - -## Summary - -`dsh-subagent-in-process-driver` is the shared run driver behind the two in-process subagent backends: it creates one child agent through the host's agent factory, applies per-child customization, drives one task to completion, and returns the child's own final output with a single quiescent disposal path. Spawn calls it with no session seed; fork calls it with the parent's completed-turn prefix. It is a library, not a standalone feature: provider backends call `startInProcessRun`, and nothing in a composition configures it. Read this page to understand the run lifecycle both in-process backends share. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -You reach this package through a provider backend, not a composition: `dsh-subagent-spawn-in-process` and `dsh-subagent-fork-in-process` each call `startInProcessRun(request, options)` and own everything around it. This page documents the lifecycle both share so you can read one backend's behavior and reason about the other. - -### What one run provides - -One call starts and drives one one-shot child. Fulfillment means the child is already published in `ctx.agents` and the caller owns the returned run; a rejected start has already quiesced the unpublished creation, so no half-created child survives. The run exposes the child's id and live agent, a `result` promise, and a `dispose()` that stops the loop, removes the agent and session, and unwinds scoped registrations. - -### The one input - -`InProcessRunOptions` is `{ seed?: SessionEvent[] }` — a fork seed of balanced parent events. Spawn omits it; fork supplies the completed-turn prefix and records its length so the result reader never mistakes seeded parent messages for child output. - -### What the child gets - -The child receives the parent's working-directory/session lineage and inherits the parent provider, model, reasoning effort, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent tool restrictions and authority are not imported. A run carries the parent's explicit sandbox override and `'never'` approval pin into the child and appends a per-run descriptor inside the child's initial turn. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -This section explains the driver's lifecycle contract and the structured-output runtime; the observable behavior is covered in [Use this package](#use-this-package). - -### Start contract - -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. Create the child through the host agent factory with the caller's required signal threaded into the creation transaction. -3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. -4. Publish the child, retain the returned handle, and drive one task. -5. Read the child's own output — its last non-empty assistant message, or its accumulated assistant text when none exists — and the final durable turn reason from the complete owned run, excluding any fork seed. - -### Cancellation and ownership - -The required request signal covers both startup and the live run. Before publication, the creation transaction observes it, rolls back, and rejects; the driver re-checks once after publication to close the handoff race, then installs a minimal live-run listener. After fulfillment the caller owns the run: provider unload does not revoke it, and `dispose()` removes the abort listener, records cancellation, and delegates to the handle's memoized quiescence transaction, which 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. - -### Structured output - -`attachStructuredRuntime(childCtx, schema)` installs the whole contract in the child's scope: a `structured_output` tool validates and stages the model's value against the requested schema; a trailing first-party order-9900 system-prompt section tells the child the tool call is the terminal answer; a `tools/result` observer commits a staged value only after the authoritative final tool result succeeds, including the enclosing `run_code` result for PTC mode sub-dispatch; and a monotonic tool guard blocks later calls after capture. A clean turn that never commits the required value reports `error`; the driver does not re-prompt. All registrations ride the child fiber and disappear with it. - -### Source map - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Run driver: creation, one-turn drive, result reading, disposal | -| [`src/structured.ts`](src/structured.ts) | Structured-output runtime: capture tool, prompt section, guard, commit | -| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | - -
- ------ - - -## Further Exploration - -Read these pages when the package-level contract is not enough; they move from the shared subagent model to the backends built on this driver and the delegation-policy decision. - -- [Subagent subsystem](../../../docs/subsystems/subagent.md) — start requests, results, provider contract, and in-process depth and seed. -- [dsh-subagent-spawn-in-process](../subagent-spawn-in-process/README.md) — the fresh-child backend built on this driver. -- [dsh-subagent-fork-in-process](../subagent-fork-in-process/README.md) — the seeded-child backend built on this driver. -- [Delegation-policy decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) — how parent sandbox and approval policy reach the child. - ------ - - -## Model Experience - -### Child-agent request - -#### What the model sees - -The shared driver sends the task verbatim as the child's user message and, when requested, shadows the persona and restricts global tool schemas, lookup, execution, and PTC mode SDK bindings in the unpublished child's fresh scope; parent restrictions are not inherited. Tool-guidance plugins can use the assembly scope to omit unavailable guidance; arbitrary static sections are not rewritten by the driver. Spawn supplies no history; fork supplies its balanced seed. - -#### Token effect - -Child input is isolated from the parent and grows through the child's own steps. A persona changes repeated prompt text; filtering changes schema or generated SDK cost, and scope-aware guidance changes with the visible capabilities. - -#### KV Cache effect - -Independent of the parent request cache. The child's later history is append-only, while persona, tool-filter, generated-SDK, provider, or model changes establish a different child prefix. - -### Structured-output system prompt, schema, and results - -#### What the model sees - -A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with the requested schema and the exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Its canonical acknowledgement is `{ recorded: true }`, rendered as `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `` is not executed``. - -##### Structured-output instruction - -```markdown -When you have your final answer, you MUST report it by calling the `structured_output` tool with arguments matching its parameter schema exactly. Do not finish with a plain text answer: only the tool call counts as your result. -``` - -#### Token effect - -Fixed instruction and capability tokens are paid only by that child. Result text enters the child history, while the captured value alone becomes the parent result. - -#### KV Cache effect - -Prefix-stable inside the child while the structured-output instruction and schema are unchanged. Changing the schema or capability may invalidate the child's cache from that early segment; results append in child and parent histories. - -### Parent start error, indirectly - -#### What the model sees - -Through `dsh-tool-subagent`, invalid depth state becomes exactly `Error: agent subagentDepth must be a non-negative safe integer`, `Error: subagent child depth exceeds the safe-integer range`, or `Error: subagent depth exceeds maxDepth `. A pre-publication cancellation passes its abort reason through the registry's `Error: ` wrapper. - -#### Token effect - -Zero tokens on a successful start; only the failed parent tool call retains this text. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - -### Parent result, indirectly - -#### What the model sees - -The driver extracts only the child's own last assistant output or captured structured value; seeded parent messages and intermediate child work do not become the result. - -#### Token effect - -The parent receives one data-dependent result through the consumer; all other child tokens stay in the child session. - -#### 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 - - - - -These limits define what an in-process one-shot run cannot do; they are current package constraints. - -- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process one-shot 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. - - -### Dev Note - -
-Working context for maintainers — click to expand - -None. - -
diff --git a/packages/subagent/subagent-in-process-driver/README.zh.md b/packages/subagent/subagent-in-process-driver/README.zh.md deleted file mode 100644 index 5ee7447829..0000000000 --- a/packages/subagent/subagent-in-process-driver/README.zh.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -description: "共享进程内 subagent 运行驱动器,供维护者与后端作者理解或扩展 spawn 与 fork 的运行生命周期。" -kind: "package-library" ---- - -# @deepseek-ai/dsh-subagent-in-process-driver - -[English](README.md) | 中文 - -## 概述 - -`dsh-subagent-in-process-driver` 是两个进程内 subagent 后端共用的运行驱动器:它通过宿主的 agent(智能体)工厂创建一个子 agent,应用按子 agent 的定制,把一项任务驱动到完成,并以单一完全停稳的 dispose(资源释放)路径返回子 agent 自身的最终输出。spawn 调用它时不传入会话初始内容;fork 调用它时传入父级已完成轮次的前缀。它是库而非独立功能:提供方后端调用 `startInProcessRun`,组合中没有任何东西配置它。阅读本页可理解两个进程内后端共享的运行生命周期。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -你通过提供方后端而非组合到达本包:`dsh-subagent-spawn-in-process` 与 `dsh-subagent-fork-in-process` 各自调用 `startInProcessRun(request, options)` 并拥有其外围的一切。本页记录两者共享的生命周期,使你读懂一个后端的行为后即可推断另一个。 - -### 一次运行提供什么 - -一次调用启动并驱动一个一次性子 agent。调用兑现意味着子 agent 已发布到 `ctx.agents`,调用方拥有返回的运行;启动被拒绝时,未发布的创建已经完全停稳,因此不会有创建到一半的子 agent 存活。运行暴露子 agent 的 id 与在线 agent、一个 `result` promise,以及一个 `dispose()`——它会停止循环、移除 agent 与会话,并撤销作用域内的注册。 - -### 唯一输入 - -`InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`——fork 的已配平父级事件初始内容。spawn 省略该值;fork 提供已完成轮次前缀并记录其长度,使结果读取器不会把作为初始内容的父级消息误认为子 agent 输出。 - -### 子 agent 获得什么 - -子 agent 获得父级的工作目录/会话谱系,除非 `request.agentOptions` 覆盖,否则继承父级的提供方、模型、推理强度与输出 token 上限。它获得全新的扁平注册作用域:父级工具限制与权限不会被导入。一次运行会把父级显式的沙箱覆盖项与 `'never'` 审批钉定带入子 agent,并在子 agent 的初始轮次内追加一份每次运行的描述符。 - ------ - - -## 理解实现 - -
-实现细节——点击展开 - -本节解释驱动器的生命周期约定与结构化输出运行时;可观察行为已在[使用本包](#use-this-package)中说明。 - -### 启动约定 - -驱动器按以下顺序运行: - -1. 校验父级深度与可选的绝对 `maxDepth`,然后把子级深度推导为父级深度加一,并持久化到子级会话 header。 -2. 通过宿主 agent 工厂创建子 agent,并把调用方必需的信号传入创建事务。 -3. 在该事务未发布的设置窗口内,安装请求的 persona、工具限制与结构化输出运行时。 -4. 发布子 agent,保留返回的句柄,并驱动一项任务。 -5. 从完整的自有运行中读取子 agent 自身的输出——最后一条非空 assistant 消息,若无则取其累积的 assistant 文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。 - -### 取消与所有权 - -必需的请求信号同时覆盖启动阶段与实时运行。发布前,创建事务会观察它、回滚并拒绝;驱动器在发布后再检查一次以消除交接竞态,然后安装最小化的实时运行监听器。兑现后,调用方拥有该运行:提供方插件卸载不会撤销它;`dispose()` 会移除中止监听器、记录取消,并委托给句柄经记忆化的完全停稳事务——后者停止循环、移除 agent 与会话,并撤销作用域内的注册。取消流程会接管所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。 - -### 结构化输出 - -`attachStructuredRuntime(childCtx, schema)` 会在子 agent 作用域中安装完整约定:`structured_output` 工具按请求的 schema 校验并暂存模型值;位于末尾、顺序为 9900 的 first-party 系统提示词段告诉子 agent 该工具调用就是终态答案;`tools/result` 观察器只在该次执行的权威最终工具结果成功后提交暂存值,包括 PTC mode 子分派外层的 `run_code` 结果;单调工具防护会在捕获后阻止后续调用。正常结束却始终未提交必需值的轮次会报告 `error`;驱动器不会重新提示。所有注册都附着于子 agent fiber,并随其一同消失。 - -### 源码地图 - -| 文件 | 职责 | -|---|---| -| [`src/index.ts`](src/index.ts) | 运行驱动器:创建、单轮驱动、结果读取、dispose | -| [`src/structured.ts`](src/structured.ts) | 结构化输出运行时:捕获工具、提示词段、防护、提交 | -| — | 不发布运行时不变式伴生入口;本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 | - -
- ------ - - -## 进一步探索 - -当包级约定不够用时阅读以下页面;它们从共享 subagent 模型进入构建于本驱动器之上的后端,以及委派策略决策。 - -- [Subagent 子系统](../../../docs/subsystems/subagent.zh.md)——启动请求、结果、提供方约定与进程内深度和初始内容。 -- [dsh-subagent-spawn-in-process](../subagent-spawn-in-process/README.zh.md)——构建于本驱动器之上的全新子 agent 后端。 -- [dsh-subagent-fork-in-process](../subagent-fork-in-process/README.zh.md)——构建于本驱动器之上的带初始内容的子 agent 后端。 -- [委派策略决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md)——父级沙箱与审批策略如何到达子 agent。 - ------ - - -## 模型体验 - -### 子 agent 请求 - -#### 模型看到什么 - -共享驱动器把任务逐字作为子 agent 的用户消息发送;若有请求,还会在未发布子 agent 的全新作用域中遮蔽 persona,并限制全局工具 schema、查找、执行与 PTC mode SDK 绑定。父级限制不会被继承。工具指导插件可以使用组装 scope 省略不可用工具的指导;驱动器不会改写任意静态段落。spawn 不提供历史;fork 提供其已配平的初始内容。 - -#### Token 影响 - -子 agent 输入与父级隔离,并随子 agent 自身的步骤增长。persona 会改变重复提示词文本;过滤会改变 schema 或生成 SDK 的成本,使用 scope 的指导内容也会随可见能力变化。 - -#### KV Cache 影响 - -与父级请求缓存相互独立。子 agent 后续历史仅追加,而 persona、工具过滤、生成 SDK、提供方或模型变化会建立不同的子 agent 前缀。 - -### 结构化输出系统提示词、schema 与结果 - -#### 模型看到什么 - -结构化运行会添加下方的结构化输出指令,并添加子 agent 作用域的 `structured_output` 定义,其参数使用请求的 schema,精确描述为 `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` 该仅运行时存在的定义不在已生成并随产品发布的[工具包索引](../../../docs/tool-catalog.zh.md#tool-package-map)中。其规范确认值是 `{ recorded: true }`,渲染为 `Structured output recorded.`;后续调用会变为 ``Error: structured output already recorded: the run is complete, so `` is not executed``。 - -##### 结构化输出指令 - -```markdown -When you have your final answer, you MUST report it by calling the `structured_output` tool with arguments matching its parameter schema exactly. Do not finish with a plain text answer: only the tool call counts as your result. -``` - -#### Token 影响 - -固定指令与能力产生的 token 仅由该子 agent 承担。结果文本进入子 agent 历史,而只有捕获的值会成为父级结果。 - -#### KV Cache 影响 - -只要结构化输出指令与 schema 不变,子 agent 内部的前缀就保持稳定。更改 schema 或能力可能从该早期片段开始使子 agent 缓存失效;结果会分别追加到子 agent 与父级历史中。 - -### 父级启动错误(间接) - -#### 模型看到什么 - -通过 `dsh-tool-subagent`,无效深度状态会精确变为 `Error: agent subagentDepth must be a non-negative safe integer`、`Error: subagent child depth exceeds the safe-integer range` 或 `Error: subagent depth exceeds maxDepth `。发布前取消的中止原因会通过注册表的 `Error: ` 包装传递。 - -#### Token 影响 - -启动成功时为零 token;只有失败的父级工具调用会保留这段文本。 - -#### KV Cache 影响 - -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 - -### 父级结果(间接) - -#### 模型看到什么 - -驱动器只提取子 agent 自身最后的 assistant 输出或捕获的结构化值;作为初始内容的父级消息与子 agent 中间工作不会成为结果。 - -#### Token 影响 - -父级通过消费方接收一个依赖数据的结果;其他所有子 agent token 都留在子 agent 会话中。 - -#### KV Cache 影响 - -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 - -## 已知限制与延期工作 - - - - -这些限制说明进程内一次性运行不能做什么;它们是当前包约束。 - -- **运行不公开 `sendMessage`/`resume`**——进程内一次性运行不具备这些可选运行时能力。 -- **结构化捕获只接受 `defineTool` schema 子集**——不支持的 JSON Schema 构造会在子 agent 创建前失败;需要更广 schema 词汇的提供方必须采用不同的运行时。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -无。 - -
diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json deleted file mode 100644 index 9c8aa2d912..0000000000 --- a/packages/subagent/subagent-in-process-driver/package.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-subagent-in-process-driver", - "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.6-alpha.1", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/subagent/subagent-in-process-driver" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-fs-sandbox": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tool-fs": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^" - }, - "dependencies": { - "@deepseek-ai/dsh-brand": "workspace:^" - } -} diff --git a/packages/subagent/subagent-in-process-driver/src/index.ts b/packages/subagent/subagent-in-process-driver/src/index.ts deleted file mode 100644 index 91348b2da9..0000000000 --- a/packages/subagent/subagent-in-process-driver/src/index.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * 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-in-process-driver - */ - -import { randomUUID } from 'node:crypto' -import type { Context } from '@deepseek-ai/cordis' -import { brandString } from '@deepseek-ai/dsh-brand' -import { foldConsumedWork } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' -import { SessionLogOffset } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId, SessionLogOffset as SessionLogOffsetType, TurnEndReason } from '@deepseek-ai/dsh-session' -import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' -import { - appendDelegatedPolicyOverrides, - applyChildComposition, - assertSubagentMaxDepth, - captureDelegatedPolicyOverrides, - childSessionMeta, - finalAssistantOutput, - resolveChildAgentOptions, - resolveChildDepth, -} from '@deepseek-ai/dsh-subagent' -import type { - ResolvedSubagentStartRequest, - SubagentDescriptorData, - SubagentResult, - SubagentRun, - SubagentStopReason, -} from '@deepseek-ai/dsh-subagent' -import { - attachStructuredRuntime, - type StructuredAttachment, -} from './structured.ts' - -export { - STRUCTURED_OUTPUT_TOOL, - STRUCTURED_OUTPUT_INSTRUCTION, -} from './structured.ts' - -/** Map a session turn outcome to the subagent seam's terminal vocabulary. */ -function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { - switch (reason?.kind) { - case 'completed': - return 'completed' - case 'max-tokens': - return 'max-tokens' - case 'aborted': - return 'aborted' - // A pre-step rejection discarded the claimed prompt: the task was - // declined, and the caller must not read the run as done. - case 'blocked': - return 'refusal' - case 'error': - case 'interrupted': - default: - return 'error' - } -} - -/** Extra inputs the spawn and fork providers supply to the shared driver. */ -export interface InProcessRunOptions { - /** Completed-turn seed for fork, or undefined for a fresh spawn. */ - readonly seed?: readonly SessionEvent[] -} - -/** Error used when cancellation wins before the child publication boundary. */ -function prePublicationAbort(): Error { - return new Error('subagent request was aborted before child publication') -} - -/** Append one one-shot descriptor inside the child's initial turn before its first request. */ -function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { - let appended = false - childCtx.on('agent/pre-step', async ({ agent }, next) => { - const decision = await next() - if (!appended && decision.kind === 'enter') { - appended = true - agent.session.append('subagent/descriptor', descriptor) - } - return decision - }) -} - -/** - * Establish and drive one in-process one-shot child. Fulfillment means the agent - * is already published in the registry and transfers its turn, cancellation, - * and disposal work through the returned run. Rejection means the agent - * factory's unpublished creation transaction reached quiescence without - * publishing a child. Every start appends its resolved 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 published holder-owned run. - */ -export async function startInProcessRun( - request: ResolvedSubagentStartRequest, - options: InProcessRunOptions, -): Promise { - assertSubagentMaxDepth(request.maxDepth) - if (request.signal.aborted) throw prePublicationAbort() - const parent = request.parent - const childDepth = resolveChildDepth(parent, request.maxDepth) - - const childId = brandString(randomUUID()) - const seed = options.seed - const activationBoundary = SessionLogOffset(seed?.length ?? 0) - - // Capture before the first await: a later parent switch belongs to the - // parent's future. - const inherited = captureDelegatedPolicyOverrides(parent) - - let structured: StructuredAttachment | undefined - const setup = (childCtx: Context, child: Agent): void => { - appendDelegatedPolicyOverrides(child.session, inherited) - applyChildComposition(childCtx, parent, { - persona: request.persona, - toolFilter: request.toolFilter, - }) - if (request.outputSchema !== undefined) { - structured = attachStructuredRuntime(childCtx, request.outputSchema) - } - attachDescriptorAppend(childCtx, request.descriptor) - } - - const handle = await parent.ctx.agents.create({ - sessionId: childId, - parentAgent: parent, - meta: childSessionMeta(parent, childDepth, seed !== undefined), - ...seed !== undefined ? { seed } : {}, - ...seed === undefined ? {} : { inheritedEventCount: activationBoundary }, - agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), - signal: request.signal, - setup, - }) - return drivePublishedRun( - handle, - request.signal, - request.prompt, - childId, - activationBoundary, - structured, - ) -} - -/** - * Wrap a published child in the single run lifecycle that owns signal handoff, - * one turn, result settlement, and quiescent disposal. - */ -function drivePublishedRun( - handle: AgentHandle, - signal: AbortSignal, - prompt: ContentBlock[], - childId: SessionId, - boundary: SessionLogOffsetType, - structured: StructuredAttachment | undefined, -): SubagentRun { - const child = handle.agent - const flags = { cancelled: false } - const onAbort = (): void => { - flags.cancelled = true - child.cancel({ kind: 'parent' }) - } - signal.addEventListener('abort', onAbort, { once: true }) - // Agent creation detaches its creation-only listener before returning. The - // post-registration check closes that handoff without treating an already - // published child as a failed start. - if (signal.aborted) onAbort() - - const result: Promise = (async () => { - try { - if (!flags.cancelled) { - child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } })) - await child.whenIdle() - } - return readResult( - child, - boundary, - flags.cancelled, - structured ? { captured: structured.captured() } : undefined, - ) - } finally { - signal.removeEventListener('abort', onAbort) - } - })() - - return { - id: childId, - localAgent: child, - result, - async dispose(): Promise { - signal.removeEventListener('abort', onAbort) - flags.cancelled = true - const settlements = await Promise.allSettled([handle.dispose(), result]) - const disposal = settlements[0] - // The result channel owns run faults; disposal reports only failure to - // release the published handle after both operations settle. - if (disposal.status === 'rejected') throw disposal.reason - }, - } -} - -/** Read one settled child's result from events after its activation boundary. */ -function readResult( - child: Agent, - boundary: SessionLogOffsetType, - cancelled: boolean, - structured?: { captured?: { value: unknown } | undefined }, -): SubagentResult { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. - const own = child.session.snapshotEvents(boundary) - // `droppedUnrun` is deliberately unread: a one-shot prompt is claimed by its - // awaited first turn almost immediately, and the owner's own teardown is the - // `cancelled` flag below. A cancellation with no accounting turn resolves - // `error` through `toStopReason(undefined)`, which never overstates success. - const lastEnd = foldConsumedWork(own).end - // The seam's canonical selection rule; a partial answer survives cancel and truncation. - const output: ContentBlock[] = finalAssistantOutput(own) ?? [] - const recorded = toStopReason(lastEnd?.data.reason) - // Disposal can tear the owner down before the loop records its ordinary - // `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 } - } - if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' } - } - return { output, stopReason } -} diff --git a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts deleted file mode 100644 index d11fa73b05..0000000000 --- a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm' -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' -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 InvariantRegistry 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 SubagentRuntime, { snapshotSubagentDescriptor } 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 { startInProcessRun } from '../src/index.ts' - -type Script = ConstructorParameters[0] - -async function mountInvariants(ctx: Context): Promise { - await ctx.plugin(InvariantRegistry) - await ctx.plugin(SessionInvariant) - await ctx.plugin(AgentInvariant) - await ctx.plugin(AgentLoopInvariant) -} - -async function setup(script: Script, parentOptions: Partial = {}) { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await mountInvariants(ctx) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SubagentRuntime) - const adapter = new MockAdapter(script) - ctx.llm.registerAdapter(['mock'], adapter) - const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock', ...parentOptions }) - return { ctx, parent, adapter } -} - -function request(parent: Agent, signal = new AbortController().signal) { - return { - label: 'child task', - prompt: [{ type: 'text' as const, text: 'child task' }], - parent, - signal, - descriptor: snapshotSubagentDescriptor({ - mode: 'one-shot', - provider: 'test', - label: 'child task', - }), - } -} - -function text(blocks: readonly { type: string; text?: string }[]): string { - return blocks.filter(block => block.type === 'text').map(block => block.text).join('') -} - -describe('startInProcessRun', () => { - it('returns only after publication, drives a fresh child, and disposes it', async () => { - const { ctx, parent } = await setup([textResponse('driver answer')]) - const run = await startInProcessRun(request(parent), {}) - expect(ctx.agents.get(run.id)).toBeDefined() - const result = await run.result - expect(result.stopReason).toBe('completed') - expect(text(result.output)).toBe('driver answer') - expect(ctx.agents.get(run.id)!.options.subagentDepth).toBe(1) - await run.dispose() - await run.dispose() - 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 = await 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('reports a prompt a pre-step rejection discarded as refusal, not completion', async () => { - const { ctx, parent } = await setup([]) - // A UserPromptSubmit deny or a policy plugin: the child claims its prompt, - // the rejection discards it, and the turn closes `blocked` with no step. - ctx.on('agent/pre-step', async ({ agent: subject }, next) => { - if (subject === parent) return next() - return { kind: 'reject' as const } - }) - - const run = await startInProcessRun(request(parent), {}) - await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' }) - await run.dispose() - }) - - 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) => { - 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(0) - await run.dispose() - }) - - it('keeps published run and handle disposal failures on separate channels', async () => { - const { ctx, parent } = await setup([]) - const runError = new Error('published run failed') - const disposalError = new Error('published handle disposal failed') - const beforeAgents = ctx.agents.list().length - const beforeSessions = ctx.sessions.list().length - const parentWithFailedDisposal = { - options: parent.options, - session: parent.session, - ctx: { - get: () => undefined, - agents: { - create: async (options: Parameters[0]) => { - const handle = await ctx.agents.create(options) - handle.agent.followup = () => { throw runError } - return { - ...handle, - dispose: async () => { - await handle.dispose() - throw disposalError - }, - } - }, - }, - }, - } as unknown as Agent - - const run = await startInProcessRun(request(parentWithFailedDisposal), {}) - expect(ctx.agents.get(run.id)).toBeDefined() - await expect(run.result).rejects.toBe(runError) - await expect(run.dispose()).rejects.toBe(disposalError) - expect(ctx.agents.list()).toHaveLength(beforeAgents) - expect(ctx.sessions.list()).toHaveLength(beforeSessions) - }) - it('reports the turn outcome when later metadata is appended during flush', async () => { - const { ctx, parent } = await setup([maxTokensResponse('partial answer')]) - let injected = false - ctx.on('session/flush', (session) => { - if (injected || session.header.parentSession === undefined) return - const lastEnd = session.snapshotEvents().findLast(event => event.type === 'turn/end') - if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return - injected = true - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'late metadata' }], - source: { kind: 'plugin', plugin: 'late-metadata' }, - }), { surfaceOp: 'append' }) - }) - - const run = await startInProcessRun(request(parent), {}) - const result = await run.result - const child = ctx.agents.get(run.id)! - - expect(injected).toBe(false) - expect(child.session.snapshotEvents().findLast(event => event.type === 'turn/end')) - .toMatchObject({ data: { reason: { kind: 'max-tokens' } } }) - expect(result.stopReason).toBe('max-tokens') - await run.dispose() - }) - - it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => { - // A tool-only max-tokens step records an empty assistant/message for - // usage. The result retains the preceding assistant output. - const { ctx, parent } = await setup([ - toolCallResponse('t1', 'noop', {}, 'partial one'), - [ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id: ToolCallId('t2'), name: 'noop', argumentsDelta: '{}' }, - { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('t2'), name: 'noop', arguments: '{}' } }, - { type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } }, - { type: 'finish', reason: { kind: 'max-tokens' } }, - ], - ]) - const disposeNoop = ctx.tools.register(defineContentToolFixture({ - name: 'noop', description: 'probe', parameters: {}, - execute() { return Promise.resolve([{ type: 'text', text: 'noop result' }]) }, - })) - const run = await startInProcessRun(request(parent), {}) - const result = await run.result - expect(result.stopReason).toBe('max-tokens') - expect(text(result.output)).toBe('partial one') - await run.dispose() - disposeNoop() - }) - - it('seeds a forked child but reads only the child-owned output', async () => { - const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) - await parent.whenIdle() - const seed = parent.session.snapshotEvents() - const run = await startInProcessRun(request(parent), { seed }) - const result = await run.result - expect(text(result.output)).toBe('child answer') - const child = ctx.agents.get(run.id)! - expect(child.session.header.isSeeded).toBe(true) - expect(child.session.inheritedEventCount).toBe(seed.length) - expect(child.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) - // The seeded `system/message` stays surface node 0: the child renders the - // same prompt, so its loop appends no second system node. - const seededSystem = seed.find(event => event.type === 'system/message') - expect(seededSystem).toBeDefined() - expect(child.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(1) - expect(child.session.surface.nodes[0]).toBe(seededSystem?.seq) - expect(child.session.deriveMessages()[0]).toEqual(parent.session.deriveMessages()[0]) - await run.dispose() - }) - - it('replaces a seeded system node in place when the forked child renders a different prompt', async () => { - const { ctx, parent, adapter } = await setup([textResponse('parent answer'), textResponse('child answer')]) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) - await parent.whenIdle() - const seed = parent.session.snapshotEvents() - const seededSystem = seed.find(event => event.type === 'system/message') - if (seededSystem === undefined) throw new Error('the parent log lacks a system node') - ctx.systemPrompt.section({ name: 'test:after-fork', order: 10, text: 'Registered after the fork seed.' }) - - const run = await startInProcessRun(request(parent), { seed }) - await run.result - const child = ctx.agents.get(run.id)! - const systemNodes = child.session.snapshotEvents().filter(event => event.type === 'system/message') - expect(systemNodes.map(event => event.seq)).toEqual([seededSystem.seq, systemNodes[1]?.seq]) - expect(systemNodes[1]?.surfaceOp).toEqual({ op: 'replace', startSeq: seededSystem.seq, endSeq: seededSystem.seq }) - expect(systemNodes[1]?.sourceEventSeqs).toEqual([seededSystem.seq]) - expect(child.session.surface.nodes[0]).toBe(systemNodes[1]?.seq) - const childRequest = adapter.requests.at(-1)! - expect(childRequest.system).toBeUndefined() - expect(childRequest.messages[0]).toMatchObject({ - role: 'system', - content: [{ type: 'text', text: expect.stringContaining('Registered after the fork seed.') as unknown }], - }) - await run.dispose() - }) - - it('persists the child origin and depth in its session header', async () => { - const { ctx, parent } = await setup([textResponse('child answer')]) - const run = await startInProcessRun(request(parent), {}) - await run.result - // The recursion budget is durable session data, not only runtime options — - // a depth that lived only in AgentOptions would reset to 0 on resume. - expect(ctx.agents.get(run.id)!.session.header).toMatchObject({ - origin: 'subagent', - delegationDepth: 1, - }) - await run.dispose() - }) - - it('inherits the parent output-token cap and accepts an explicit child override', async () => { - const { ctx, parent, adapter } = await setup( - [textResponse('inherited'), textResponse('overridden')], - { maxTokens: 111 }, - ) - const inherited = await startInProcessRun(request(parent), {}) - await inherited.result - expect(adapter.requests[0]?.maxTokens).toBe(111) - expect(ctx.agents.get(inherited.id)?.options.maxTokens).toBe(111) - await inherited.dispose() - - const overridden = await startInProcessRun({ - ...request(parent), - agentOptions: { maxTokens: 222 }, - }, {}) - await overridden.result - expect(adapter.requests[1]?.maxTokens).toBe(222) - expect(ctx.agents.get(overridden.id)?.options.maxTokens).toBe(222) - await overridden.dispose() - }) - - it('counts a RESUMED child by its persisted header depth, not the absent runtime depth', async () => { - // Resume rebuilds runtime options, so the durable header must keep this - // depth-1 child from delegating as though it were top-level. - const { ctx } = await setup([textResponse('unused')]) - const resumed = (await ctx.agents.create({ - sessionId: SessionId('resumed-child'), - meta: { parentSession: SessionId('root'), delegationDepth: 1 }, - agentOptions: { provider: 'mock', model: 'mock' }, - signal: new AbortController().signal, - })).agent - await expect(startInProcessRun({ ...request(resumed), maxDepth: 1 }, {})) - .rejects.toMatchObject({ name: 'SubagentDepthError', attemptedDepth: 2, maxDepth: 1 }) - }) - - it('lets runtime options deepen but never lower the persisted depth', async () => { - const { ctx } = await setup([textResponse('unused')]) - const parent = (await ctx.agents.create({ - sessionId: SessionId('deep-parent'), - meta: { delegationDepth: 2 }, - agentOptions: { provider: 'mock', model: 'mock', subagentDepth: 1 }, - signal: new AbortController().signal, - })).agent - // Persisted 2 vs runtime 1: the child is depth 3, so maxDepth 2 rejects. - await expect(startInProcessRun({ ...request(parent), maxDepth: 2 }, {})) - .rejects.toMatchObject({ name: 'SubagentDepthError', attemptedDepth: 3, maxDepth: 2 }) - }) - - it('rejects invalid and exceeded depth before publication', async () => { - const { parent } = await setup([]) - await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {})) - .rejects.toThrow('non-negative safe integer') - await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {})) - .rejects.toMatchObject({ name: 'SubagentDepthError' }) - for (const value of [Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1]) { - const malformed = { options: { subagentDepth: value }, session: { header: {} } } as unknown as Agent - await expect(startInProcessRun(request(malformed), {})) - .rejects.toThrow('agent subagentDepth must be a non-negative safe integer') - } - const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER }, session: { header: {} } } as unknown as Agent - await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError) - }) - - it('rejects an already-aborted request without publishing a child', async () => { - const { ctx, parent } = await setup([]) - const beforeAgents = ctx.agents.list().length - const beforeSessions = ctx.sessions.list().length - const controller = new AbortController() - controller.abort('too late') - await expect(startInProcessRun(request(parent, controller.signal), {})) - .rejects.toThrow('aborted before child publication') - expect(ctx.agents.list()).toHaveLength(beforeAgents) - expect(ctx.sessions.list()).toHaveLength(beforeSessions) - }) - - 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 = await 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' }) - 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() - const signalled = await startInProcessRun(request(parent, controller.signal), {}) - await new Promise(resolve => setTimeout(resolve, 30)) - controller.abort('stop child') - // No step completed a message, so the text streamed before the abort is - // the cancelled run's output. - await expect(signalled.result).resolves.toEqual({ - output: [{ type: 'text', text: 'partial' }], - stopReason: 'aborted', - }) - expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' }) - const child = parent.ctx.agents.get(signalled.id) - const turnEnd = child?.session.snapshotEvents().findLast(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' } }) - await signalled.dispose() - - const disposed = await startInProcessRun(request(parent), {}) - await new Promise(resolve => setTimeout(resolve, 30)) - await disposed.dispose() - await expect(disposed.result).resolves.toMatchObject({ stopReason: 'aborted' }) - }) - - it('cleans a failed unpublished setup before rejecting', async () => { - const { ctx, parent } = await setup([]) - const beforeAgents = ctx.agents.list().length - const beforeSessions = ctx.sessions.list().length - await expect(startInProcessRun({ - ...request(parent), - toolFilter: { deny: ['unknown-tool'] }, - }, {})).rejects.toThrow('unknown global tool') - expect(ctx.agents.list()).toHaveLength(beforeAgents) - expect(ctx.sessions.list()).toHaveLength(beforeSessions) - }) - - it('treats abort after factory publication as a cancelled run with an id', async () => { - const { ctx, parent } = await setup([]) - const controller = new AbortController() - const beforeAgents = ctx.agents.list().length - const beforeSessions = ctx.sessions.list().length - const parentWithAbortAtHandoff = { - options: parent.options, - session: parent.session, - ctx: { - // The driver's synchronous inheritance capture probes both policy - // services opportunistically; this stub composes neither. - get: () => undefined, - agents: { - create: async (options: Parameters[0]) => { - const handle = await ctx.agents.create(options) - // `create()` has detached its creation-only listener, but the - // published run has not installed its live listener yet. - controller.abort('handoff race') - return handle - }, - }, - }, - } as unknown as Agent - const run = await startInProcessRun(request(parentWithAbortAtHandoff, controller.signal), {}) - expect(ctx.agents.get(run.id)).toBeDefined() - await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' }) - await run.dispose() - expect(ctx.agents.list()).toHaveLength(beforeAgents) - expect(ctx.sessions.list()).toHaveLength(beforeSessions) - }) -}) diff --git a/packages/subagent/subagent-in-process-driver/tsconfig.json b/packages/subagent/subagent-in-process-driver/tsconfig.json deleted file mode 100644 index 7b7a015cc9..0000000000 --- a/packages/subagent/subagent-in-process-driver/tsconfig.json +++ /dev/null @@ -1,36 +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": "../subagent" - }, - { - "path": "../../core/system-prompt" - }, - { - "path": "../../core/tools" - } - ] -} diff --git a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml b/packages/subagent/subagent-spawn-in-process/README.i18n.yaml index fae3963b0d..13420914b6 100644 --- a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-spawn-in-process/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-in-process/README.md -README.md: ce3909934efa1b2f5cc57ff046b892e3ec129444 -README.zh.md: 998b90ff0827e3cccc3be787e1941ca49aaeec0e +README.md: 3aea0e18862673bd7e9baa2cdd78e1d2e17dea2f +README.zh.md: 04423644332281c5ac4b4dc013b2f453a8f503f2 diff --git a/packages/subagent/subagent-spawn-in-process/README.md b/packages/subagent/subagent-spawn-in-process/README.md index ce3909934e..3aea0e1886 100644 --- a/packages/subagent/subagent-spawn-in-process/README.md +++ b/packages/subagent/subagent-spawn-in-process/README.md @@ -51,7 +51,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### What a delegation does -One tool call starts one child and waits for its result: the child works in its own session and the parent receives only its final output, or an errored tool result when the run is cancelled, refused, truncated by its token limit, or rejected at startup. A rejected start leaves no published child; a completed run is disposed after its result is collected. +One tool call creates a background child and returns its session id immediately. The child works in its own session; the parent receives a status notice when it settles; the child reports its results through `send_message`. A rejected start leaves no published child. Programmatic callers such as workflows can await the activation result directly. ----- @@ -61,22 +61,22 @@ One tool call starts one child and waits for its result: the child works in its
Implementation internals — click to expand -This section explains how the backend is built and where the behavior in [Use this package](#use-this-package) comes from; the shared mechanics belong to the in-process driver. +This section explains provider preparation and the activation lifecycle owned by the subagent service. ### Design concept -One separation: this backend contributes only the provider registration and the decision to start fresh, while every run mechanic — depth checking, child creation, per-child customization, structured output, cancellation, result reading, and disposal — lives in `dsh-subagent-in-process-driver`. The agent factory's creation transaction owns the unpublished setup window and its rollback; after publication the caller owns the run. +This backend registers the provider and prepares a fresh child session. `dsh-subagent` owns activation depth checks, child creation, tool and persona setup, structured output, result collection, and disposal. The caller signal cancels unpublished creation; the published handle cancels and reaches quiescence through `dispose()`. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Provider registration: `Config` schema, capability declaration, `start()` | +| [`src/index.ts`](src/index.ts) | Provider registration: `Config` schema, capability declaration, `prepareContinuable()` | | — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### Run flow -A start request resolves through the subagent service, then the shared driver validates depth, mints a child session id, creates the child through the host agent factory with the caller's signal, applies persona, tool filter, and structured output inside the creation window, publishes the child, drives one task, reads the child's own final output, and disposes the handle quiescently. +`startActivation()` resolves the provider and calls `prepareContinuable()`, then creates and publishes the child. Structured output is attached separately for each activation. Callers can await `result` and use `dispose()` to wait for the entire child subtree to reach quiescence. ### Ownership and scope @@ -92,7 +92,6 @@ The child gets a fresh flat registration scope: parent tool restrictions and aut Read these pages when the package-level contract is not enough; they move from the shared subagent model to the sibling backends and exhaustive configuration. - [Subagent subsystem](../../../docs/subsystems/subagent.md) — start requests, results, live runs, and the provider contract. -- [dsh-subagent-in-process-driver](../subagent-in-process-driver/README.md) — the shared run driver this backend calls. - [dsh-subagent-fork-in-process](../subagent-fork-in-process/README.md) — the sibling backend that seeds completed parent turns. - [dsh-tool-subagent](../tool-subagent/README.md) — the model-facing delegation tool that reaches this provider. - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-subagent-spawn-in-process) — every accepted config field and its source declaration. @@ -120,7 +119,7 @@ The child's request cache is independent of the parent's. Child history grows ap #### What the model sees -Through `dsh-tool-subagent`, the parent receives only the child's final output or an errored result for a non-completed stop reason; intermediate child work never reaches it. +Through `dsh-tool-subagent`, the parent first receives the child session id, then a completion status notice; child-authored results arrive through `send_message`. Internal child tool calls remain in the child session. #### Token effect diff --git a/packages/subagent/subagent-spawn-in-process/README.zh.md b/packages/subagent/subagent-spawn-in-process/README.zh.md index 998b90ff08..0442364433 100644 --- a/packages/subagent/subagent-spawn-in-process/README.zh.md +++ b/packages/subagent/subagent-spawn-in-process/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" ### 一次委派会做什么 -一次工具调用启动一个子 agent 并等待其结果:子 agent 在自有会话中工作,父级只接收其最终输出;若运行被取消、拒绝、被 token 上限截断或在启动时被拒,则收到出错的工具结果。被拒绝的启动不会留下已发布的子 agent;完成的运行在结果收集后即被 dispose(资源释放)。 +一次工具调用创建后台子 agent 并立即返回子会话 ID。子级在自己的会话中工作,完成后父级收到状态通知,子级通过 `send_message` 汇报结果。被拒绝的启动不会留下已发布的子 agent;工作流等程序调用方可以直接等待 activation 的结果。 ----- @@ -61,22 +61,22 @@ kind: "package-reference"
实现细节——点击展开 -本节解释后端的构建方式以及[使用本包](#use-this-package)中行为的来源;共享机制属于进程内驱动器。 +本节说明提供方准备工作,以及由 subagent 服务拥有的 activation 生命周期。 ### 设计理念 -职责划分如下:本后端只贡献提供方注册与「全新开始」的决定,其余全部运行机制——深度检查、子 agent 创建、按子 agent 定制、结构化输出、取消、结果读取与 dispose——都在 `dsh-subagent-in-process-driver` 中。agent 工厂的创建事务拥有未发布设置窗口及其回滚;发布之后,调用方拥有该运行。 +本后端注册提供方并准备全新子会话。`dsh-subagent` 拥有 activation 的深度检查、子 agent 创建、工具和 persona 设置、结构化输出、结果收集及资源释放;发布前的调用方信号取消创建,发布后的句柄通过 `dispose()` 取消并等待完全停稳。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 提供方注册:`Config` schema、能力声明、`start()` | +| [`src/index.ts`](src/index.ts) | 提供方注册:`Config` schema、能力声明、`prepareContinuable()` | | — | 不发布运行时不变式伴生入口;本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 | ### 运行流程 -启动请求先由 subagent 服务解析,然后共享驱动器校验深度、生成子会话 id、通过宿主 agent 工厂以调用方信号创建子 agent、在创建窗口内应用 persona、工具过滤器与结构化输出、发布子 agent、驱动一项任务、读取子 agent 自身的最终输出,最后完全停稳地 dispose 句柄。 +`startActivation()` 解析提供方并调用 `prepareContinuable()`,随后创建并发布子 agent。每次 activation 单独安装结构化输出。调用方可以等待 `result`,并通过 `dispose()` 等待整个子树完全停稳。 ### 所有权与作用域 @@ -92,7 +92,6 @@ kind: "package-reference" 当包级约定不够用时阅读以下页面;它们从共享 subagent 模型进入兄弟后端与穷尽式配置。 - [Subagent 子系统](../../../docs/subsystems/subagent.zh.md)——启动请求、结果、实时运行与提供方约定。 -- [dsh-subagent-in-process-driver](../subagent-in-process-driver/README.zh.md)——本后端调用的共享运行驱动器。 - [dsh-subagent-fork-in-process](../subagent-fork-in-process/README.zh.md)——以已完成父级轮次作初始内容的兄弟后端。 - [dsh-tool-subagent](../tool-subagent/README.zh.md)——指向该提供方的面向模型委派工具。 - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-subagent-spawn-in-process)——每个受支持配置字段及其源声明。 @@ -120,7 +119,7 @@ kind: "package-reference" #### 模型看到什么 -通过 `dsh-tool-subagent`,父级只接收子 agent 的最终输出,或非完成终止原因对应的出错结果;子 agent 的中间工作绝不会到达父级。 +通过 `dsh-tool-subagent`,父级先收到子会话 ID,随后收到完成状态通知;子级通过 `send_message` 汇报结果。子级内部工具调用保留在子会话中。 #### Token 影响 diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 34cd0a3727..bf0f2ee2bd 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -28,7 +28,6 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { @@ -46,10 +45,10 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^" } } diff --git a/packages/subagent/subagent-spawn-in-process/src/index.ts b/packages/subagent/subagent-spawn-in-process/src/index.ts index 73811155c1..4c8aad32b9 100644 --- a/packages/subagent/subagent-spawn-in-process/src/index.ts +++ b/packages/subagent/subagent-spawn-in-process/src/index.ts @@ -10,11 +10,9 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { ContinuableCreateSpec, - ResolvedSubagentStartRequest, SubagentCapabilities, SubagentProvider, } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from '@deepseek-ai/dsh-subagent-in-process-driver' export const name = 'subagent-spawn-in-process' // `tools` is deliberately not injected: the child factory already provides it during setup, @@ -51,13 +49,6 @@ class SpawnInProcessProvider implements SubagentProvider { constructor(readonly name: string) {} - start(request: ResolvedSubagentStartRequest) { - // 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, {}) - } - prepareContinuable(): Promise { // A spawned child starts fresh, so it contributes no seed; the continuation // manager owns every later operation on it. diff --git a/packages/subagent/subagent-spawn-in-process/tests/harness.ts b/packages/subagent/subagent-spawn-in-process/tests/harness.ts index 03442bfb11..1737364fca 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/harness.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/harness.ts @@ -1,3 +1,5 @@ +import { join } from 'node:path' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -33,6 +35,7 @@ export async function spawnHarness(workdir: string): Promise { await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) + await ctx.plugin(JsonlSessionPersistence, { root: join(workdir, '.sessions') }) await ctx.plugin(SubagentRuntime) await ctx.plugin(Spawn, { providerName: 'spawn' }) // The model-facing subagent tool, bound to the spawn backend. @@ -40,13 +43,8 @@ export async function spawnHarness(workdir: string): Promise { return ctx } -export function waitForIdle(ctx: Context, agent: Agent): Promise { - return new Promise((resolve) => { - const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { - if (subject === agent && status === 'idle') { - dispose() - resolve() - } - }) - }) +export async function waitForIdle(ctx: Context, agent: Agent): Promise { + do { + await agent.whenIdle() + } while (await ctx.subagents.waitForChildren(agent)) } diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts index 5df47ab30e..db53a4d40a 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts @@ -1,3 +1,4 @@ +import { mountLocalActivations, startTestActivation as start } from '../../subagent/tests/local-activation.ts' import { createUserMessage, type GenerateOptions } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context, symbols, type EffectMeta } from '@deepseek-ai/cordis' @@ -10,11 +11,11 @@ import InvariantRegistry 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 SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' -import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver' +import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' type Script = ConstructorParameters[0] @@ -38,6 +39,7 @@ async function setup(script: Script) { const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) + await mountLocalActivations(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -50,9 +52,7 @@ function text(blocks: { type: string; text?: string }[]): string { return blocks.filter(b => b.type === 'text').map(b => b.text).join('') } -function start(ctx: Context, provider: string, request: Omit & { signal?: AbortSignal }) { - return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request }) -} + /** Invoke the child lifecycle effect while its parent-owned setup is still unpublished. */ function disposeChildLifecycle(parent: Agent): void { @@ -183,7 +183,7 @@ describe('dsh-subagent-spawn-in-process', () => { controller.abort() const { ctx, parent } = await setup([]) await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })) - .rejects.toThrow('aborted before child publication') + .rejects.toBe(controller.signal.reason) }) it('same-tick cancellation rejects start and prevents child publication', async () => { @@ -208,15 +208,13 @@ describe('dsh-subagent-spawn-in-process', () => { expect(published).toEqual([]) }) - it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => { + it('disposing a running child settles the activation as aborted', async () => { // 'hang' makes the child's model stream one chunk then wait until aborted. const controller = new AbortController() const { ctx, parent } = await setup(['hang']) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) - // Let the child's turn start, then abort via the request signal (the - // backend bridges it to child.cancel()). await new Promise(r => setTimeout(r, 30)) - controller.abort() + await run.dispose() const result = await run.result expect(result.stopReason).toBe('aborted') await run.dispose() @@ -231,13 +229,9 @@ describe('dsh-subagent-spawn-in-process', () => { expect(result.stopReason).toBe('aborted') }) - it('a one-shot run exposes neither steer nor resume; continuable creation is a provider capability', async () => { + it('the provider supplies fresh local preparation for managed activations', async () => { const { ctx, parent } = await setup([textResponse('x')]) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) - // 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) await run.result @@ -335,6 +329,7 @@ describe('dsh-subagent-spawn-in-process', () => { const adapter = new MockAdapter(['hang']) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) + await mountLocalActivations(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -353,7 +348,7 @@ describe('dsh-subagent-spawn-in-process', () => { await fiber.dispose() expect(ctx.subagents.getProvider('spawn')).toBeUndefined() expect(ctx.agents.get(run.id)).toBeDefined() - controller.abort('test complete') + await run.dispose() const result = await run.result expect(result.stopReason).toBe('aborted') expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() @@ -471,7 +466,7 @@ describe('dsh-subagent-spawn-in-process', () => { await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent: parentHandle.agent, - })).rejects.toThrow(/inactive context/) + })).rejects.toThrow(/exact live parent agent/) expect(ctx.agents.list().length).toBe(before) expect(ctx.sessions.list()).toHaveLength(sessionsBefore) expect(published).toEqual([]) @@ -500,7 +495,7 @@ describe('dsh-subagent-spawn-in-process', () => { // The factory has entered its awaited unpublished setup transaction. The // parent context owns that transaction, so disposal wins without an // observer ever seeing the child. - await expect(starting).rejects.toThrow(/owner disposed during setup|inactive context/) + await expect(starting).rejects.toThrow(/owner disposed during setup|inactive context|child lifecycle effect not found/) await parentHandle.dispose() expect(published).toEqual([]) diff --git a/packages/subagent/subagent-spawn-in-process/tsconfig.json b/packages/subagent/subagent-spawn-in-process/tsconfig.json index ed3c2cd6d9..bf3c6a6808 100644 --- a/packages/subagent/subagent-spawn-in-process/tsconfig.json +++ b/packages/subagent/subagent-spawn-in-process/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../subagent" - }, - { - "path": "../subagent-in-process-driver" } ] } diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 93de7aa966..ba32692cb3 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: 6e5ee7b657bfbc8ca6bb6ef075e669e4fc65d3ce -README.zh.md: 9e8b9b89d9d6927b8397705d4375b62f68152903 +README.md: 1ce968aaaabd8b3f3acfe0798886e893e02dc145 +README.zh.md: ef858a66dc35b1dffdd6fe3451a8dae69a05d732 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 6e5ee7b657..1ce968aaaa 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-subagent` to delegate work to named child agents, collect their results, and continue supported child conversations across turns. A composition can offer in-process, ACP, SDK, Codex, or Claude Code children side by side. Choose one-shot children for a single result or continuable children for later messages and interruption. You can also inspect available children, their mode, activity, and lineage without loading or resuming them. Enable at least one supported child backend and a delegation tool. +Use `dsh-subagent` to delegate work through named providers and collect managed activation results. Local spawn and fork children retain durable sessions for later messages; ACP, DSH SDK, Codex, and Claude Code execute once. Every child shares admission, cancellation, ownership, and discovery. Compose the service with a provider and a delegation tool. ## Table of Contents @@ -29,7 +29,7 @@ This package is the contract every delegation setup shares. You enable it by mou ### Enabling delegation -Mount the service with a provider and the delegation tool. The provider registers under the name you configure (the in-process spawn backend defaults to `spawn`); the tool row names that provider so the model sees a static tool. A minimal one-shot setup: +Mount the service with a provider and the delegation tool. The provider registers under its configured name; each tool row selects one provider. A minimal setup: ```yaml - name: '@deepseek-ai/dsh-subagent' @@ -40,23 +40,23 @@ Mount the service with a provider and the delegation tool. The provider register toolName: subagent ``` -An agent that calls the tool gets the child's final answer as the tool result. Mounting the service alone changes nothing: nothing can delegate until a provider and a tool are composed. +The tool returns a child session id immediately. The parent receives a completion notice when the background activation settles. Mounting the service alone enables no delegation. ### Delegation settings The Host exposes delegation defaults in the `subagent` settings section. User values override this plugin's composition; reset removes the user override. `maxDepth` defaults to `1` and supplies the delegation tools' depth when their own configuration omits it. An explicit tool depth, including `provider-managed`, takes precedence. Depth `0` disables delegation through tools inheriting this setting; depth `1` permits direct children only. Changes apply on the next delegation attempt. Direct service callers continue to supply their own optional request depth. -### Continuable capacity +### Activation capacity -Set `maxActiveSubagents` on the host `dsh-subagent` plugin to limit live children sharing uninterrupted continuable parent links. It defaults to `8` and accepts positive safe integers. A non-continuable parent starts a separate pool and does not consume a slot; continuable descendants inherit that pool. Fresh creation and cold resume reserve before reconstructing the Agent, and cleanup returns the slot after handle disposal. A waiting parent, pending inbox work, and an Activation being stopped still occupy slots. Messages to a resident child reuse its slot. One-shot and external-provider runs are outside this limit. Pool inheritance does not cross a one-shot parent; its continuable children share a separate pool. Depth remains the delegation tool's separate policy. +Set `maxActiveSubagents` on the host plugin to limit live and materializing children in each activation tree. It defaults to `8` and accepts positive safe integers. A top-level parent starts a pool shared by its local and external descendants; the root parent consumes no slot. Creation and cold resume reserve before yielding, and disposal returns the slot only after cleanup. Waiting parents, pending inbox work, and stopping activations remain counted. Sending to a resident child reuses its slot. Delegation depth is a separate policy. The current `maxActiveSubagents` value is sampled before every new or cold-resumed Activation. Raising it admits more children in existing trees; lowering it leaves resident children running and refuses further admissions until usage is below the limit. At capacity, creation or cold resume rejects with `ACTIVATION_LIMIT_REACHED` (browser prompts receive `subagent/delivery-unavailable`): wait for a child to finish or continue using the existing agents. Admission does not queue, because a parent waiting for descendants must not wait for its own occupied slot. Slots are process-local and do not constrain cumulative Session history or token usage. -### One-shot and continuable children +### Local and external children -One-shot children run once and settle with a single result, plus an optional structured output and a safe diagnostic on failure. A start request may override the child Agent's provider, model, reasoning effort, and output-token limit through `agentOptions`; every requested option requires the provider's matching capability. Continuable children keep a durable session and accept later messages in order: the caller receives a stable child id, sends adjacent-Agent messages, and can interrupt the current turn without destroying the child. The tool row's `backgroundMode` picks the shape (`one-shot` by default, or `continuable` on providers that support it). +`startActivation({ provider, label, request, signal, delivery })` returns `{ childId, messageId?, result, dispose }`. Local children accept later messages and can cold-resume; external children expose one execution and reject continuation. `request.agentOptions` overrides supported child model settings. `delivery: 'parent'` supplies the model-facing completion notice; `delivery: 'caller'` lets workflows await the result without a parent notice or initial return guidance. Local parent delivery requires persistence; caller delivery can use an ephemeral local session. Cold resume requires persistence and Session query. ### Messaging, interrupting, and discovering @@ -64,7 +64,7 @@ Every exact live Agent can use `sendMessage()` with a direct continuable child; ### Failure and recovery -Requests that need a capability the chosen provider lacks fail loudly at start rather than being silently ignored. A failed child run returns a stop reason, and provider backends add a safe diagnostic; a cancelled request settles as `aborted`. Children are isolated: a crashed or misbehaving child cannot corrupt the parent's session. +Unsupported provider capabilities reject creation. The caller signal cancels only unpublished work; after publication, `dispose()` cancels the exact activation and waits for its descendants and resources. `result` carries output, optional structured data, and a stop reason; infrastructure failures may reject it. Result readiness can precede subtree quiescence, so callers that require cleanup also await `dispose()`. ----- @@ -79,8 +79,8 @@ This section explains how the service is built and where the observable behavior ### Design concept - **One service, many providers.** The service is a named-provider registry; each backend registers under a unique name and a request picks one by name. -- **Two child shapes.** One-shot runs transfer ownership at publication; continuable children keep a durable Session and at most one process-local Activation. -- **Fulfillment is publication.** A provider's `start()` fulfills only after a real child exists, so the caller always owns a live run or nothing. +- **One managed lifetime.** Local Agents and external executions share activation ownership, capacity, result delivery, and disposal. +- **Fulfillment is publication.** `startActivation()` returns only after the child is accepted and its handle can cancel that exact execution. - **Trusted same-process values.** Requests, descriptors, and results are borrowed immutable; serialization and hostile-input validation belong at process and wire boundaries. ### Source map @@ -93,6 +93,9 @@ This section explains how the service is built and where the observable behavior | [`src/continuation-messages.ts`](src/continuation-messages.ts) | Adjacent-Agent messages, return guidance, and settlement notices | | [`src/internal.ts`](src/internal.ts) | Host-only Queue and Steer adapters plus standard adjacent-Agent messaging markers | | [`src/inbox.ts`](src/inbox.ts) | Activation-local Queue and Steer admission plus the synchronous closing cutoff | +| [`src/activation-driver.ts`](src/activation-driver.ts) | Local Agent and external execution adapters | +| [`src/structured.ts`](src/structured.ts) | Activation-scoped structured capture and guards | +| [`src/external-records.ts`](src/external-records.ts) | Parent-owned external execution records | | [`src/types.ts`](src/types.ts) | Public request, result, and provider contracts | | [`src/descriptor.ts`](src/descriptor.ts) | Versioned `subagent/descriptor` session-event vocabulary | | [`src/child-agent.ts`](src/child-agent.ts) | Child composition, delegated policy, depth helpers | @@ -100,19 +103,19 @@ This section explains how the service is built and where the observable behavior | [`src/control.ts`](src/control.ts) | Browser control assembly: catalog activity sampling, browser-zone validation, failure codes | | [`src/control-types.ts`](src/control-types.ts) | Client-safe catalog row, control requests, receipts, and failures | -### One-shot flow +### Provider preparation and structured output -A request is validated against the provider's advertised capabilities, a durable descriptor is snapshotted, and the provider builds the child. Both in-process providers advertise `agentOptions`: child creation merges requested fields over the provider, model, and reasoning effort in the parent's latest logged request, falls back to creation options before the first request, and retains the configured token limit. They also snapshot delegated permission state before the first await: an Auto or Full access parent gives the child the same `permission/preset` identity, while the existing sandbox override and approval-policy pin continue to apply. Recording both identities prevents an older same-bundle fork value from winning. Auto then reviews every supported child call independently: ordinary project-local work is low risk and allowed, medium-risk work requires explicit action, exact-target and scope authorization from the existing creation prompt or an authenticated human/direct-parent message, without conflicting human limits, while high-risk work is always denied. The reviewer derives that context from `parentSession` and existing messages; delegation adds no parent call metadata, delegation records, review receipt, or Session format. A route change without an explicit effort clears the inherited route-owned effort so the selected model resolves its default. DSH SDK also advertises `agentOptions` but runs a separate child runtime, so it does not inherit Auto; ACP, Codex, and Claude Code likewise retain their own permission systems after the parent delegation call passes review. On success the run is published and ownership transfers to the caller; on failure the provider rolls back every unpublished resource. The result carries the child's final output, an optional structured value, a stop reason, and an optional safe diagnostic. +The service validates requested capabilities before creation. Local providers supply only `prepareContinuable()`: spawn returns fresh state and fork returns a completed-turn seed. The manager captures the parent model settings, delegated permissions, and composition before creating the child. External providers retain `start()` as a transport adapter and retain their own permission systems. The common manager owns their published execution. Structured tools, instructions, validation, and terminal guards belong to one local activation; they are not persisted as resume configuration. The child can submit its structured result after owned descendants release. A captured result closes further input to that activation, while later cold resume has no structured schema. -### Continuable flow +### Activation settlement -The manager reserves a child identity, resolves the durable descriptor, creates (or cold-resumes) the child Agent, installs it in an Activation, and submits the prompt. Model-authored messages cross one parent/child edge through fixed Steer scheduling; browser human prompts choose Queue or best-effort Steer through an internal adapter, while other host protocols may retain Queue for distinct turns. A Session queue command admits a live subagent-owned Agent only from its own continuable descriptor. Settlement waits for Agent activity to finish, an empty Inbox, and no owned children, then flushes final Session state with admission open. Under the child lock, the manager revalidates the wake generation, Session sequence, Inbox, and owned children; the synchronous task entry of `Agent.runMaintenance()` claims the idle phase and closes the private subagent Inbox in the same JavaScript turn before handle disposal. An absent direct-child Activation cold-resumes from the persisted session. When a resident Activation settles, the manager tells the child's direct parent in the parent's own turn stream. +The manager reserves child identity and capacity, materializes a local Agent or external execution, and accepts the initial task. Local `result` becomes ready when Agent activity finishes and the inbox is empty. Final disposal additionally waits for owned descendants and revalidates activity after the final Session flush. Closing admission and disposing the handle prevent late work from entering a released Agent. Parent delivery emits its notice after settlement; caller delivery leaves collection to the awaiting workflow. Headless hosts repeat `agent.whenIdle()` and `waitForChildren(agent)` until no child work remains, so completion notices can produce the final parent answer. -Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). Its view preserves parent catalog event order in O(D) time for D facts. [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. +Successful local creation appends a `subagent/catalog` fact to the parent Session. External executions append `subagent/external-start` and `subagent/external-end` with identity and retained results, without fabricating a child Session. Discovery combines these records with live state and real child Sessions. Projections exclude inherited facts and validate durable payloads. [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns local catalog ordering and persistence costs. ### Ownership and invariants -- **Publication is the boundary** — before it the provider owns the setup and must roll back on failure; after it the caller owns the run and must dispose it. +- **The manager owns accepted work** — unpublished failures roll back; published handles cancel their exact activation and await child-first disposal. - **Registration is effect-scoped** — removing a provider blocks new starts but never revokes accepted runs. - **Agent-message authority is exact adjacency** — `sendMessage()` requires the exact live sender; every sender may target a direct continuable child, while only a sender with a resident continuable Activation may target its direct parent. - **The descriptor is log-only** — a session event absent from model history and retained across compaction; a continuable descriptor records the resolved child provider, model, and reasoning effort explicitly for cold resume. @@ -143,11 +146,11 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -One user-role parent message opening with the outcome — `Background subagent finished and will do no further work unless you send it more.`, or the matching line for a child that was stopped, ran out of room, declined, or failed — followed by `Its closing message:` and the nonempty text blocks from the child's final assistant output, preserving their content and order. Reasoning and other nontext blocks are excluded; when no nonempty text remains, the notice says `It left no closing message.` This runtime-owned notice is distinct from model-authored parent/child messages, which use `sendMessage()` and `AgentMessageSource`; delegation schemas and model controls belong to the Consumer packages. +Parent delivery emits a user-role status notice for local children, whose results reach the parent through child-authored `sendMessage()` calls. External notices also include the child’s nonempty final text blocks and state that further messages are unsupported. Reasoning and other nontext blocks are excluded. Caller delivery emits no notice; SDK lifecycle notifications retain the complete child output. #### Token effect -One notice per settled Activation in the parent's request, sized by the child's final text. A child that sends its own message and then settles costs the parent both. +One notice per settled activation using parent delivery. Caller delivery adds no completion message; its consumer owns result presentation. #### KV Cache effect @@ -180,7 +183,7 @@ Prefix-stable within a child: the statement never changes during the child's lif These limits define when the seam is a poor fit or needs special operational care. They are current package constraints, not a general delegation comparison or a task backlog. -- **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus, and remote providers need an Activation ownership contract before they can support continuable children. +- **External children execute once** — ACP, DSH SDK, Codex, and Claude Code have no local child Session and accept no follow-up input. Their parent-owned records remain discoverable after disposal. - **Adjacent model messaging only** — `sendMessage()` requires an exact live sender; every sender may target a direct continuable child, while only a sender with a resident continuable Activation may target its direct parent. Browser prompts use a separate human Queue-or-Steer control path. - **A direct parent must remain live for child-to-parent delivery** — the service has no durable parent mailbox; a missing parent rejects the message instead of accepting work it cannot wake. - **Wake gap during cancellation convergence** — a follow-up accepted after an interrupt signal but before the driver becomes idle stays queued until another waking send. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 9e8b9b89d9..ef858a66dc 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `dsh-subagent` 把工作委派给具名子 agent、收集结果,并跨轮次继续受支持的子级对话。一个组合可以并排提供进程内、ACP(Agent Client Protocol)、SDK、Codex 或 Claude Code 子级。需要单个结果时选择一次性子级;需要后续消息与中断能力时选择可继续子级。你还可以检查可用子级及其模式、活动状态与谱系,而无需加载或恢复它们。启用时需要至少一个受支持的子级后端和一个委派工具。 +`dsh-subagent` 通过具名提供方委派工作,并收集受管理 activation 的结果。本地 spawn 和 fork 子级保留持久会话以接受后续消息;ACP、DSH SDK、Codex 与 Claude Code 执行一次。所有子级共享准入、取消、所有权与发现机制。使用时组合服务、提供方和委派工具。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 启用委派 -把服务与一个提供方和委派工具一起挂载。提供方以你配置的名称注册(进程内 spawn 后端默认为 `spawn`);工具行指名该提供方,让模型看到一个静态工具。一个最小的一次性配置: +挂载服务、提供方与委派工具。提供方按配置名称注册,每个工具行选择一个提供方。最小配置如下: ```yaml - name: '@deepseek-ai/dsh-subagent' @@ -40,23 +40,23 @@ kind: "package-reference" toolName: subagent ``` -调用该工具的 agent 会把子 agent 的最终答案作为工具结果收到。只挂载服务本身不会改变任何行为:在组合出提供方和工具之前,什么都不能委派。 +工具立即返回子会话 ID,后台 activation 结算后父级收到完成通知。仅挂载服务本身不会启用委派。 ### 委派设置 Host 在 `subagent` 设置分节中提供委派默认值。用户值覆盖本插件的组合配置;恢复默认会删除用户覆盖。`maxDepth` 默认为 `1`,在委派工具自身未配置深度时提供默认值。工具显式指定的深度(包括 `provider-managed`)优先。深度 `0` 禁止继承此设置的工具委派;深度 `1` 只允许直接子代理。修改在下一次委派时生效。直接调用服务的调用方仍自行提供可选的请求深度。 -### 可续接子代理容量 +### Activation 容量 -在 Host 的 `dsh-subagent` 插件上设置 `maxActiveSubagents`,限制通过连续可续接父子关系共享名额的存活子代理数。默认值为 `8`,接受正安全整数。非可续接父代理建立独立的池,自身不占名额;可续接后代继承该池。新建和冷恢复在重建 Agent 前预占名额,清理在 handle 释放后归还名额。等待后代的父代理、有待处理收件箱内容的代理以及正在停止的 Activation 仍占名额。向驻留子代理发送消息复用其名额。一次性和外部提供方运行不受此限制。池的继承不会跨越一次性父代理;其可续接子代理共享独立的池。深度仍由委派工具的独立策略决定。 +在 Host 插件上设置 `maxActiveSubagents`,限制每棵 activation 树中在线和正在创建的子级数量。默认值为 `8`,接受正安全整数。顶层父级建立由本地和外部后代共享的池,根父级不占名额。新建和冷恢复在让出执行前预留名额,资源释放完成后才归还。等待后代、有待处理收件箱内容以及正在停止的 activation 仍计入容量;向驻留子级发送消息复用其名额。委派深度是独立策略。 每次新建或冷恢复 Activation 前都会读取当前 `maxActiveSubagents`。调高后已有树可接纳更多子代理;调低后驻留子代理继续运行,使用量降至上限以下前拒绝新接纳。 容量耗尽时,新建或冷恢复以 `ACTIVATION_LIMIT_REACHED` 拒绝(浏览器消息返回 `subagent/delivery-unavailable`):等待子代理完成,或继续使用现有代理。接纳不会排队,避免等待后代的父代理又等待自己占用的名额。名额仅存在于当前进程,不限制累计 Session 历史或 token 用量。 -### 一次性与可继续子级 +### 本地与外部子级 -一次性子 agent 只运行一次,并以单个结果结算,可附带可选的结构化输出与失败时的安全诊断。启动请求可以通过 `agentOptions` 覆盖子 Agent 的提供方、模型、推理强度与输出 token 上限;每个请求的选项都要求提供方声明对应能力。可继续子 agent 保留持久会话并按顺序接受后续消息:调用方收到稳定的子 agent id、发送相邻 Agent 消息,并可中断当前轮次而不销毁子 agent。工具行的 `backgroundMode` 选择形态(默认 `one-shot`,或在支持的提供方上使用 `continuable`)。 +`startActivation({ provider, label, request, signal, delivery })` 返回 `{ childId, messageId?, result, dispose }`。本地子级接受后续消息并支持冷恢复;外部子级只暴露一次执行,拒绝继续输入。`request.agentOptions` 覆盖后端支持的子级模型设置。`delivery: 'parent'` 提供面向模型的完成通知;`delivery: 'caller'` 让工作流等待结果,不发送父级通知,也不附加初始返回指导。本地父级投递需要持久化,调用方投递可以使用临时本地会话;冷恢复需要持久化与 Session 查询服务。 ### 消息、中断与发现 @@ -64,7 +64,7 @@ Host 在 `subagent` 设置分节中提供委派默认值。用户值覆盖本插 ### 失败与恢复 -需要所选提供方不具备的能力的请求会在启动时明确报错,而不会被静默忽略。失败的子 agent 运行会返回停止原因,提供方后端还会附加安全诊断;被取消的请求以 `aborted` 结算。子 agent 相互隔离:崩溃或行为异常的子 agent 无法破坏父级会话。 +不受提供方支持的能力会使创建失败。调用方信号仅取消尚未发布的工作;发布后通过 `dispose()` 取消该次 activation,并等待其后代和资源释放。`result` 携带输出、可选结构化数据与停止原因,基础设施故障可能使其拒绝。结果就绪可以早于子树完全停稳,需要完成清理的调用方还应等待 `dispose()`。 ----- @@ -79,8 +79,8 @@ Host 在 `subagent` 设置分节中提供委派默认值。用户值覆盖本插 ### 设计理念 - **一个服务,多个提供方。** 服务是具名提供方注册表;每个后端以唯一名称注册,请求按名称选择一个。 -- **两种子级形态。** 一次性运行在发布时转移所有权;可继续子级保留持久 Session,且同一时刻至多一个进程内 Activation。 -- **兑现即发布。** 提供方的 `start()` 只有在真实子 agent 存在后才兑现,因此调用方要么拥有一段在线运行,要么一无所有。 +- **统一受管理生命周期。** 本地 Agent 和外部执行共享 activation 所有权、容量、结果投递与资源释放。 +- **兑现即发布。** `startActivation()` 仅在子级已接受且句柄可取消该次执行后返回。 - **同进程值可信。** 请求、描述符与结果按不可变约定借用;序列化与不可信输入校验属于进程与协议边界。 ### 源码地图 @@ -93,6 +93,9 @@ Host 在 `subagent` 设置分节中提供委派默认值。用户值覆盖本插 | [`src/continuation-messages.ts`](src/continuation-messages.ts) | 相邻 Agent 消息、返回指引与结算通知 | | [`src/internal.ts`](src/internal.ts) | Host 专用 Queue 与 Steer 适配器,以及标准相邻 Agent 消息标记 | | [`src/inbox.ts`](src/inbox.ts) | Activation 局部的 Queue 和 Steer 准入,以及同步 closing cutoff | +| [`src/activation-driver.ts`](src/activation-driver.ts) | 本地 Agent 与外部执行适配器 | +| [`src/structured.ts`](src/structured.ts) | Activation 局部结构化捕获与保护 | +| [`src/external-records.ts`](src/external-records.ts) | 父级拥有的外部执行记录 | | [`src/types.ts`](src/types.ts) | 公开的请求、结果与提供方约定 | | [`src/descriptor.ts`](src/descriptor.ts) | 版本化的 `subagent/descriptor` 会话事件词汇 | | [`src/child-agent.ts`](src/child-agent.ts) | 子级组装、委派策略、深度辅助函数 | @@ -100,19 +103,19 @@ Host 在 `subagent` 设置分节中提供委派默认值。用户值覆盖本插 | [`src/control.ts`](src/control.ts) | 浏览器控制面组装:目录活性采样、浏览器时区校验、失败分码 | | [`src/control-types.ts`](src/control-types.ts) | client-safe 的目录行、控制面请求、回执与失败 | -### 一次性流程 +### 提供方准备与结构化输出 -请求先对照提供方声明的能力进行校验,随后对持久化描述符做快照,再由提供方构建子 agent。两个进程内提供方都声明 `agentOptions`:创建子级时把请求字段叠加到父级最新已记录请求的提供方、模型与推理强度之上;父级还没有请求时回退到创建选项,并保留配置的 token 上限。它们还会在第一次 await 前快照委派权限状态:Auto 或 Full access 父级让子级获得相同的 `permission/preset` 身份,而既有沙箱覆盖与审批策略固定仍然生效;同时记录这两个身份可防止 fork 中更早的同旋钮组合身份胜出。Auto 随后会独立审查 child 的每个受支持调用:普通项目内工作为低风险并直接允许;中风险工作必须在既有创建 prompt 或已核验的 human/直接父级消息中获得动作、准确目标和范围的明确授权,且不与 human 限制冲突;高风险工作始终拒绝。reviewer 从 `parentSession` 与既有消息派生这份上下文;委派不会新增父 call metadata、委派记录、review receipt 或 Session format。更改路由而不显式指定推理强度时,会清除继承的路由自有强度,使所选模型解析自己的默认值。DSH SDK 也声明 `agentOptions`,但会运行独立子运行时,因此不继承 Auto;ACP、Codex 与 Claude Code 同样在父级委派调用通过审查后保留各自的权限系统。成功时运行被发布、所有权转移给调用方;失败时提供方回滚每个尚未发布的资源。结果携带子 agent 的最终输出、可选的结构化值、停止原因与可选的安全诊断。 +服务在创建前校验请求能力。本地提供方只提供 `prepareContinuable()`:spawn 返回全新状态,fork 返回已完成轮次的初始内容。管理器在创建子级前捕获父级模型设置、委派权限和组合。外部提供方保留 `start()` 作为传输适配器,并保留各自的权限系统;统一管理器拥有已发布的执行。结构化工具、指令、校验与终止保护仅属于一次本地 activation,不作为恢复配置持久化。子代理只能在所属后代释放后提交结构化结果。结果捕获后,该 activation 拒绝继续输入;后续冷恢复不带结构化 schema。 -### 可继续流程 +### Activation 结算 -管理器预留 child 身份、解析持久化描述符、创建(或冷恢复)child、把它安装进 Activation 并提交提示词。模型编写的消息通过固定 Steer 调度跨一条 parent/child 边;浏览器人类 prompt 通过内部适配器选择 Queue 或 best-effort Steer,其他 host 协议仍可保留 Queue 以创建独立轮次。Session queue command 仅根据 child 自身的 continuable descriptor 准入在线 subagent-owned Agent。Settlement 会等待 Agent 活动结束、Inbox 为空且没有所拥有子级,再在准入开放时 flush 最终 Session 状态。管理器随后在 child lock 内重新验证 wake generation、Session 序号、Inbox 与所拥有子级;`Agent.runMaintenance()` 的同步 task 入口会占用 idle 阶段,并在同一个 JavaScript turn 内关闭私有 subagent Inbox,然后才释放句柄。直接 child 不存在 Activation 时会从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在 parent 自身的轮次流中告知该 child 的直接 parent。 +管理器预留子级身份与容量,创建本地 Agent 或外部执行,并接受初始任务。本地 Agent 活动结束且收件箱为空时,`result` 就绪;最终释放还需等待所拥有的后代,并在最终 Session flush 后重新验证活动状态。关闭准入并释放句柄可防止迟到工作进入已释放的 Agent。父级投递在结算后发送通知;调用方投递由等待中的工作流收集。Headless 宿主交替等待 `agent.whenIdle()` 与 `waitForChildren(agent)`,直至没有子级工作,让完成通知可驱动父级生成最终答案。 -本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。其视图对 D 条事实以 O(D) 时间保留父目录事件顺序。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。 +本地创建成功时,父 Session 追加 `subagent/catalog` 事实。外部执行追加携带身份与保留结果的 `subagent/external-start` 和 `subagent/external-end`,不伪造子 Session。发现机制结合这些记录、在线状态及真实子 Session。Projection 排除继承事实并校验持久化载荷。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明本地目录排序与持久化成本。 ### 所有权与不变式 -- **发布即边界**——发布前提供方拥有设置并须在失败时回滚;发布后调用方拥有运行并须 dispose(资源释放)它。 +- **管理器拥有已接受工作**——未发布的失败会回滚;已发布句柄取消对应 activation,并等待子级优先释放。 - **注册受 effect 作用域约束**——移除提供方会阻止新启动,但绝不撤销已接受的运行。 - **Agent 消息权限基于确切相邻关系**——`sendMessage()` 要求确切在线 sender;每个 sender 都可以指定直接可继续 child,只有具备驻留可继续 Activation 的 sender 可以指定自己的直接 parent。 - **描述符仅进日志**——它是会话事件,不进入模型历史,并跨压缩(compaction)保留;可继续描述符会显式记录解析后的子级提供方、模型与推理强度,用于冷恢复。 @@ -143,11 +146,11 @@ Host 在 `subagent` 设置分节中提供委派默认值。用户值覆盖本插 #### 模型看到什么 -一条用户角色的父级消息,开头是结果本身——`Background subagent finished and will do no further work unless you send it more.`,或子级被停止、耗尽额度、拒绝任务或失败时的对应句子——随后是 `Its closing message:` 与子级最终 assistant 输出中的非空文本块,保留原始内容与顺序。推理与其他非文本块不会进入通知;若没有剩余的非空文本,通知会写明 `It left no closing message.`。这条由运行时生成的通知与模型编写的父子消息相互独立;后者使用 `sendMessage()` 与 `AgentMessageSource`。委派 schema 与模型控制工具归消费方包所有。 +父级投递为本地子级发送 user-role 状态通知,子级通过自行编写的 `sendMessage()` 调用向父级汇报结果。外部通知还包含子级最终输出中的非空文本块,并说明不支持后续消息;推理及其他非文本块被排除。调用方投递不发送通知;SDK 生命周期通知保留完整子级输出。 #### Token 影响 -父级请求中,每个已结算的 Activation 一条通知,长度取决于子级的最终文本。如果子级先发送自己的消息再结算,父级请求会同时承担两者。 +父级投递为每个结算的 activation 添加一条通知。调用方投递不添加完成消息,由其消费者负责展示结果。 #### KV Cache 影响 @@ -180,7 +183,7 @@ You are a delegated subagent: your permission scope was fixed when you were star 这些限制说明该 seam 何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用委派对比或任务积压。 -- **ACP 子级仍为一次性,且无法通过追踪枚举**——ACP 运行在父级会话语料中没有本地子会话,远程提供方需要 Activation 所有权约定才能支持可继续子级。 +- **外部子级执行一次**——ACP、DSH SDK、Codex 与 Claude Code 没有本地子 Session,也不接受后续输入。释放后仍可发现父级拥有的执行记录。 - **仅允许相邻模型消息**——`sendMessage()` 要求确切在线 sender;每个 sender 都可以指定直接可继续 child,只有具备驻留可继续 Activation 的 sender 可以指定自己的直接 parent。浏览器提示使用独立的人类 Queue 或 Steer 控制路径。 - **child 到 parent 的投递要求直接 parent 保持在线**——服务没有持久 parent mailbox;parent 缺失时会拒绝消息,而非接受无法唤醒的工作。 - **取消收敛期间存在唤醒缺口**——中断信号发出后、driver 进入 idle 前被接受的后续消息会保持排队,直到另一条唤醒发送到达。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 16394a723a..bbaba3c141 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -65,7 +65,6 @@ "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", @@ -108,9 +107,6 @@ "@deepseek-ai/dsh-session-query": { "optional": true }, - "@deepseek-ai/dsh-jobs": { - "optional": true - }, "@deepseek-ai/dsh-user-approval": { "optional": true }, @@ -124,7 +120,6 @@ "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", @@ -144,6 +139,13 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^" } } diff --git a/packages/subagent/subagent/src/activation-driver.ts b/packages/subagent/subagent/src/activation-driver.ts new file mode 100644 index 0000000000..f1e1ea04c0 --- /dev/null +++ b/packages/subagent/subagent/src/activation-driver.ts @@ -0,0 +1,238 @@ +/** + * Activation execution and settlement over a local Agent or an external run. + * The manager owns admission and residency; each driver owns its execution handle. + * + * @module @deepseek-ai/dsh-subagent/activation-driver + */ + +import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' +import type { SessionLogOffset, UserMessage } from '@deepseek-ai/dsh-session' +import { finalAssistantOutput } from './assistant-output.ts' +import { SubagentError } from './error.ts' +import { epochStopReason } from './lifecycle.ts' +import type { SubagentResult, SubagentRun } from './types.ts' + +/** Execution operations used by the activation manager. */ +export interface ActivationDriver { + /** The local Agent, absent for an external product run. */ + readonly agent: Agent | undefined + /** Whether accepted input remains unclaimed. */ + readonly hasPending: boolean + /** Monotonic revision for detecting work committed during settlement. */ + readonly version: number + /** + * Wait until execution and maintenance reach quiescence. + * @returns fulfillment after current activity settles. + */ + whenIdle(): Promise + /** + * Accept input into the driver's existing inbox. + * @param message - identified input accepted by the manager. + * @param delivery - queue a new turn or steer the nearest step. + * @throws when the backend does not accept additional input. + */ + deliver(message: UserMessage, delivery: 'queue' | 'steer'): void + /** + * Cancel active execution and optionally preserve unclaimed input. + * @param kind - the actor requesting cancellation. + * @param keepInbox - preserve local queued input when true. + */ + cancel(kind: 'user' | 'parent', keepInbox?: boolean): void + /** + * Flush owned durable state; failures remain visible to the manager. + * @returns fulfillment after persistence completes. + */ + flush(): Promise + /** + * Read the settled outcome of this activation's own execution. + * @returns final output and stop reason, with any committed structured value. + * @throws when an external result has not settled yet. + */ + capture(): SubagentResult + /** + * Claim idle execution and synchronously close manager admission. + * The callback starts teardown but must not await it or throw. + * @param close - synchronously close admission and start resource release. + * @returns whether idle execution was claimed and admission closed. + */ + closeWhenIdle(close: () => void): boolean + /** + * Subscribe for the driver's lifetime to input removal. + * @param wake - wake the manager's settlement observation. + */ + onInputRemoved(wake: () => void): void + /** + * Release the owned execution handle and reach quiescence. + * @returns fulfillment after resource release. + */ + dispose(): Promise +} + +/** Read access to a requested structured result after tool acceptance. */ +export interface ActivationStructuredCapture { + /** + * Read the value committed by the structured-output tool. + * @returns the committed value, or undefined when no value was accepted. + */ + captured(): { value: unknown } | undefined +} + +/** Local execution with output restricted to events produced during residency. */ +export class LocalActivationDriver implements ActivationDriver { + readonly agent: Agent + private readonly boundary: SessionLogOffset + + /** + * Own one published local handle before submitting activation input. + * @param handle - the local Agent and its resource release operation. + * @param structured - committed-value reader when structured output is required. + */ + constructor( + private readonly handle: AgentHandle, + private readonly structured?: ActivationStructuredCapture, + ) { + this.agent = handle.agent + this.boundary = this.agent.session.seq + } + + get hasPending(): boolean { + return this.agent.inbox.nextTurn.length > 0 || this.agent.inbox.nextStep.length > 0 + } + + get version(): number { + return this.agent.session.seq + } + + whenIdle(): Promise { + return this.agent.whenIdle() + } + + deliver(message: UserMessage, delivery: 'queue' | 'steer'): void { + if (this.structured?.captured() !== undefined) { + throw new SubagentError('subagent already submitted its structured result; wait for this activation to close before sending another message', 'INPUT_CLOSED') + } + if (delivery === 'steer') this.agent.steer(message) + else this.agent.followup(message) + } + + cancel(kind: 'user' | 'parent', keepInbox?: boolean): void { + if (keepInbox === undefined) this.agent.cancel({ kind }) + else this.agent.cancel({ kind }, { keepInbox }) + } + + async flush(): Promise { + await this.agent.ctx.sessions.flush(this.agent.session) + } + + capture(): SubagentResult { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + const events = this.agent.session.snapshotEvents(this.boundary) + const output = finalAssistantOutput(events) ?? [] + const stopReason = epochStopReason(events) + if (this.structured !== undefined) { + const captured = this.structured.captured() + if (captured !== undefined) return { output, stopReason, structured: captured.value } + if (stopReason === 'completed') return { output, stopReason: 'error' } + } + return { output, stopReason } + } + + closeWhenIdle(close: () => void): boolean { + try { + void this.agent.runMaintenance(() => { + close() + return Promise.resolve() + }) + return true + } catch (_error: unknown) { + // Another activity already owns the Agent's idle phase. + return false + } + } + + onInputRemoved(wake: () => void): void { + this.agent.ctx.on('agent/inbox/claimed', wake) + this.agent.ctx.on('agent/inbox/discarded', wake) + } + + dispose(): Promise { + return this.handle.dispose() + } +} + +/** External execution that settles once and accepts no further input. */ +export class ExternalActivationDriver implements ActivationDriver { + readonly agent = undefined + readonly hasPending = false + private terminal: SubagentResult | undefined + private failure: { error: unknown } | undefined + private readonly settled: Promise + private disposal: Promise | undefined + + /** + * Observe one published external run until its terminal result is available. + * @param run - provider-owned execution handle. + * @param controller - cancellation controller passed to the provider at start. + */ + constructor( + private readonly run: SubagentRun, + private readonly controller: AbortController, + ) { + this.settled = run.result.then( + (result) => { this.terminal = result }, + (error: unknown) => { + this.failure = { error } + this.terminal = { + output: [], + stopReason: 'error', + diagnostic: 'External subagent execution failed before returning a result.', + } + }, + ) + } + + get version(): number { + return this.terminal === undefined ? 0 : 1 + } + + whenIdle(): Promise { + return this.settled + } + + deliver(_message: UserMessage, _delivery: 'queue' | 'steer'): void { + throw new SubagentError( + `subagent "${this.run.id}" does not accept follow-up input`, + 'NOT_CONTINUABLE', + ) + } + + cancel(kind: 'user' | 'parent', _keepInbox?: boolean): void { + this.controller.abort({ kind }) + void this.dispose().catch(() => undefined) + } + + flush(): Promise { + return Promise.resolve() + } + + capture(): SubagentResult { + if (this.failure !== undefined) throw this.failure.error + if (this.terminal === undefined) { + throw new SubagentError(`subagent "${this.run.id}" has not settled`, 'NOT_IDLE') + } + return this.terminal + } + + closeWhenIdle(close: () => void): boolean { + if (this.terminal === undefined) return false + close() + return true + } + + onInputRemoved(_wake: () => void): void {} + + dispose(): Promise { + this.disposal ??= Promise.resolve().then(() => this.run.dispose()) + return this.disposal + } +} diff --git a/packages/subagent/subagent/src/catalog.ts b/packages/subagent/subagent/src/catalog.ts index 3ec29bbd2f..0e54e68395 100644 --- a/packages/subagent/subagent/src/catalog.ts +++ b/packages/subagent/subagent/src/catalog.ts @@ -133,24 +133,14 @@ export const subagentCatalogProjectionDefinition = { */ export function establishCatalogChild( parent: Session, - child: SessionHeader, - descriptor: - | { readonly mode: 'one-shot'; readonly label?: string } - | { readonly mode: 'continuable'; readonly label: string }, + child: Pick, + descriptor: { readonly mode: 'continuable'; readonly label: string }, ): void { - parent.append('subagent/catalog', descriptor.mode === 'one-shot' - ? { - version: SUBAGENT_CATALOG_VERSION, - childId: child.id, - childCreatedAt: child.createdAt, - mode: descriptor.mode, - ...descriptor.label === undefined ? {} : { label: descriptor.label }, - } - : { - version: SUBAGENT_CATALOG_VERSION, - childId: child.id, - childCreatedAt: child.createdAt, - mode: descriptor.mode, - label: descriptor.label, - }) + parent.append('subagent/catalog', { + version: SUBAGENT_CATALOG_VERSION, + childId: child.id, + childCreatedAt: child.createdAt, + mode: descriptor.mode, + label: descriptor.label, + }) } diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index 1455f5037c..cb7f7f6f7e 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -1,9 +1,7 @@ /** * Shared in-process child composition: the delegation-depth budget, the * durable session metadata, the resolved child `AgentOptions`, the delegated - * policy seed, 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, lineage stamping, and delegation policy have one home. + * policy seed, and the scoped setup used by local activations. * * @module @deepseek-ai/dsh-subagent/child-agent */ diff --git a/packages/subagent/subagent/src/continuation-activation.ts b/packages/subagent/subagent/src/continuation-activation.ts index d995e9f2b4..82aa804120 100644 --- a/packages/subagent/subagent/src/continuation-activation.ts +++ b/packages/subagent/subagent/src/continuation-activation.ts @@ -1,5 +1,5 @@ /** - * Process-local Activation ownership for continuable subagents: admission, + * Process-local activation ownership for every subagent: admission, * parent-child residency, serialized delivery, settlement, and disposal. * * The continuation manager owns durable request orchestration and delegates @@ -12,7 +12,6 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent, - AgentHandle, AgentOptions, CreateAgentOptions, } from '@deepseek-ai/dsh-agent' @@ -24,7 +23,12 @@ import type { SessionLogOffset as SessionLogOffsetType, UserMessage, } from '@deepseek-ai/dsh-session' -import type { ToolRestriction } from '@deepseek-ai/dsh-tools' +import type { ToolRestriction, ObjectJsonSchema } from '@deepseek-ai/dsh-tools' +import { LocalActivationDriver, ExternalActivationDriver } from './activation-driver.ts' +import type { ActivationDriver } from './activation-driver.ts' +import { attachStructuredRuntime } from './structured.ts' +import type { StructuredAttachment } from './structured.ts' +import type { SubagentResult, SubagentRun } from './types.ts' import { appendDelegatedPolicyOverrides, applyChildComposition, @@ -33,6 +37,7 @@ import type { DelegatedPolicyOverrides } from './child-agent.ts' import { createSettlementMessage } from './continuation-messages.ts' import type { SubagentDescriptorData } from './descriptor.ts' import { SubagentError } from './error.ts' +import { recordExternalEnd } from './external-records.ts' import { SubagentInbox } from './inbox.ts' import type { SubagentDelivery } from './inbox.ts' import type { ActivationObserver, ActivationTerminal } from './lifecycle.ts' @@ -57,12 +62,11 @@ class ActivationPool { } /** - * One residency epoch for a reconstructed continuable child Agent. It directly - * owns the published `AgentHandle`; the registry's private activation-owner - * scope is its structural Cordis owner. + * One residency epoch for a local child or an external execution. Its driver + * owns the execution handle; the registry owns admission and resource release. */ export interface Activation { - /** Shared capacity for this Activation and all its continuable descendants. */ + /** Shared capacity for this Activation and all its managed descendants. */ readonly pool: ActivationPool /** Return this epoch's slot after its handle has finished disposal. */ readonly releaseSlot: () => void @@ -78,7 +82,11 @@ export interface Activation { /** The provider name recorded in the durable descriptor. */ readonly provider: string /** The retained live Agent handle, disposed exactly once at settlement. */ - readonly handle: AgentHandle + readonly driver: ActivationDriver + readonly parent: Agent + readonly delivery: 'parent' | 'caller' + readonly result: PromiseWithResolvers + readonly released: PromiseWithResolvers /** The Activation-local admission and close wrapper around the handle's Agent inbox. */ readonly inbox: SubagentInbox /** @@ -128,6 +136,9 @@ export interface MaterializeInputs { agentOptions: AgentOptions composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } signal: AbortSignal + delivery?: 'parent' | 'caller' | undefined + outputSchema?: ObjectJsonSchema | undefined + external?: (signal: AbortSignal) => Promise } /** @@ -138,6 +149,7 @@ export interface MaterializeInputs { interface Materialization { readonly lineage: readonly Agent[] readonly settled: Promise + readonly controller: AbortController } /** Residency state observed by the natural-settlement watcher. */ @@ -148,6 +160,11 @@ type SettlementAttempt = | Exclude | { readonly done: Promise } +/** Render only the safe outer message from execution or teardown failures. */ +function failureMessage(error: unknown): string { + return error instanceof Error ? error.message : 'unknown teardown failure' +} + /** Serialize each durable child's delivery, release, and disposal. */ export class ChildLock { private tails = new Map>() @@ -172,7 +189,7 @@ export class ChildLock { } } -/** Own the complete process-local lifetime of continuable child Activations. */ +/** Own the complete process-local lifetime of local and external activations. */ export class ContinuableActivationRegistry { /** Child session id → its live Activation. Process-local, never durable. */ private readonly resident = new Map() @@ -238,7 +255,7 @@ export class ContinuableActivationRegistry { * @param childId - proposed durable child session id. */ assertChildIdAvailable(childId: SessionId): void { - if (this.ctx.agents.get(childId) !== undefined || this.ctx.get('sessions')?.get(childId) !== undefined) { + if (this.resident.has(childId) || this.ctx.agents.get(childId) !== undefined || this.ctx.get('sessions')?.get(childId) !== undefined) { throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD') } } @@ -255,7 +272,7 @@ export class ContinuableActivationRegistry { */ holdOwnership(parent: Agent, childId: SessionId): () => void { const parentActivation = this.resident.get(parent.id) - if (parentActivation === undefined || parentActivation.handle.agent !== parent) return () => {} + if (parentActivation === undefined || parentActivation.driver.agent !== parent) return () => {} if (parentActivation.inbox.closing !== undefined) { throw new SubagentError( `subagent parent "${parent.id}" is being disposed; the child was not established`, @@ -304,7 +321,7 @@ export class ContinuableActivationRegistry { const activation = this.resident.get(targetSessionId) if (activation === undefined) return if (authority.kind === 'user') { - if (activation.handle.agent.session.header.parentSession !== authority.parentSessionId) { + if (activation.parentSession !== authority.parentSessionId) { throw new SubagentError( `subagent "${targetSessionId}" belongs to another parent session`, 'UNAUTHORIZED', @@ -319,10 +336,7 @@ export class ContinuableActivationRegistry { // Disposal already stopped the target with a whole-Activation teardown; // a second cancel would be a redundant signal on a closing handle. if (activation.inbox.closing !== undefined) return - activation.handle.agent.cancel( - authority.kind === 'user' ? { kind: 'user' } : { kind: 'parent' }, - { keepInbox: true }, - ) + activation.driver.cancel(authority.kind === 'user' ? 'user' : 'parent', true) } /** @@ -333,7 +347,7 @@ export class ContinuableActivationRegistry { */ sendWaking(parent: Agent, message: UserMessage, delivery: SubagentDelivery): void { const parentActivation = this.resident.get(parent.id) - if (parentActivation !== undefined && parentActivation.handle.agent === parent) { + if (parentActivation !== undefined && parentActivation.driver.agent === parent) { try { parentActivation.inbox.deliver(message, delivery) } finally { @@ -352,6 +366,7 @@ export class ContinuableActivationRegistry { */ async drain(): Promise { this.draining = true + for (const pending of this.materializations) pending.controller.abort() await Promise.all([...this.materializations].map(materialization => materialization.settled)) const owned = new Set() for (const activation of this.resident.values()) { @@ -362,8 +377,24 @@ export class ContinuableActivationRegistry { } /** - * Stop only the continuable descendants of exact live host-owned parents. - * @param parents - exact live roots whose continuable descendants must stop. + * Wait for the parent's currently owned child work to release its resources. + * @param parent - exact live parent whose child work is observed. + * @returns whether any child work was observed. + */ + async waitForChildren(parent: Agent): Promise { + const pending = [...this.materializations].filter(item => item.lineage.includes(parent)) + const children = [...this.resident.values()].filter(item => item.driver.agent !== parent && item.ancestry.has(parent)) + if (pending.length === 0 && children.length === 0) return false + await Promise.all([ + ...pending.map(item => item.settled), + ...children.map(item => item.released.promise), + ]) + return true + } + + /** + * Stop the managed descendants of exact live host-owned parents. + * @param parents - exact live roots whose managed descendants must stop. */ async drainDescendants(parents: readonly Agent[]): Promise { const roots = new Set(parents.filter(parent => this.ctx.agents.get(parent.id) === parent)) @@ -375,14 +406,14 @@ export class ContinuableActivationRegistry { const targets: Activation[] = [] for (const activation of this.resident.values()) { - const lineage = this.liveLineage(activation.handle.agent) - const owners = [...roots].filter(root => activation.handle.agent !== root + const lineage = this.liveLineage(activation.driver.agent ?? activation.parent) + const owners = [...roots].filter(root => activation.driver.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) + if (activation.driver.agent !== undefined) members.add(activation.driver.agent) for (const agent of lineage) members.add(agent) } } @@ -392,6 +423,7 @@ export class ContinuableActivationRegistry { const members = this.closingMembers(owner) for (const agent of materialization.lineage) members.add(agent) } + if (owners.length > 0) materialization.controller.abort() return owners.length > 0 }) @@ -448,8 +480,8 @@ export class ContinuableActivationRegistry { if (closing === undefined) return throw new SubagentError( closing === 'manager' - ? 'continuable subagents are draining; the operation was not admitted' - : `continuable subagents below parent "${closing.id}" are draining; the operation was not admitted`, + ? 'subagents are draining; the operation was not admitted' + : `subagents below parent "${closing.id}" are draining; the operation was not admitted`, 'DRAINING', ) } @@ -488,13 +520,18 @@ export class ContinuableActivationRegistry { const pool = this.resident.get(inputs.parent.id)?.pool ?? this.rootPool(inputs.parent) const releaseSlot = pool.reserve(this.maxActiveSubagents()) const settled = Promise.withResolvers() + const controller = new AbortController() const materialization: Materialization = { lineage, settled: settled.promise, + controller, } this.materializations.add(materialization) - return this.materializeTracked(inputs, lineage, pool, releaseSlot).catch((error: unknown) => { + return this.materializeTracked( + { ...inputs, signal: AbortSignal.any([inputs.signal, controller.signal]) }, lineage, pool, releaseSlot, + ).catch((error: unknown) => { releaseSlot() + if (error instanceof Error && error.name === 'AbortError') this.assertAdmitting(inputs.parent) throw error }).finally(() => { this.materializations.delete(materialization) @@ -523,7 +560,7 @@ export class ContinuableActivationRegistry { this.authorizeLineage( parent, activation.childId, - activation.handle.agent.session.header.parentSession, + activation.parentSession, ) this.acquireOwnership(parent, activation.childId) try { @@ -560,8 +597,8 @@ export class ContinuableActivationRegistry { const reasons = failures.filter(failure => failure !== undefined) if (reasons.length > 0) { throw new SubagentError( - `continuable subagent teardown failed for ${reasons.length} ${failureSubject}: ` - + reasons.map(reason => errorChain(reason)).join('; '), + `subagent teardown failed for ${reasons.length} ${failureSubject}: ` + + reasons.map(reason => failureMessage(reason)).join('; '), 'ACTIVATION_TEARDOWN_FAILED', ) } @@ -618,7 +655,9 @@ export class ContinuableActivationRegistry { pool: ActivationPool, releaseSlot: () => void, ): Promise { - const { childId, provider, parent, create } = inputs + const { provider, parent, create } = inputs + let childId = inputs.childId + let structured: StructuredAttachment | undefined inputs.signal.throwIfAborted() const setup = (childCtx: Context, child: Agent): void => { // Only fresh creation appends the descriptor and delegated policy after @@ -628,26 +667,49 @@ export class ContinuableActivationRegistry { appendDelegatedPolicyOverrides(child.session, create.delegatedPolicies) } applyChildComposition(childCtx, parent, inputs.composition) + if (inputs.outputSchema !== undefined) { + structured = attachStructuredRuntime(childCtx, inputs.outputSchema, () => this.resident.get(childId)?.ownedChildren.size === 0) + } + } + let driver: ActivationDriver + if (inputs.external !== undefined) { + const controller = new AbortController() + const cancelStartup = (): void => { controller.abort(inputs.signal.reason) } + inputs.signal.addEventListener('abort', cancelStartup, { once: true }) + try { + const run = await inputs.external(controller.signal) + driver = new ExternalActivationDriver(run, controller) + childId = run.id + try { this.assertChildIdAvailable(childId) } catch (error: unknown) { + await driver.dispose() + throw error + } + } finally { + inputs.signal.removeEventListener('abort', cancelStartup) + } + } else { + const handle = create === undefined + ? await this.ownerCtx.agents.resume({ + resumeSessionId: childId, + parentAgent: parent, + agentOptions: inputs.agentOptions, + signal: inputs.signal, + setup, + }) + : await this.ownerCtx.agents.create({ + sessionId: childId, + parentAgent: parent, + meta: create.meta, + ...(create.seed === undefined ? {} : { seed: create.seed }), + inheritedEventCount: create.inheritedEventCount, + agentOptions: inputs.agentOptions, + signal: inputs.signal, + setup, + }) + + driver = new LocalActivationDriver(handle, structured) } const observer = this.observeActivation(provider, childId, parent) - const handle: AgentHandle = create === undefined - ? await this.ownerCtx.agents.resume({ - resumeSessionId: childId, - parentAgent: parent, - agentOptions: inputs.agentOptions, - signal: inputs.signal, - setup, - }) - : await this.ownerCtx.agents.create({ - sessionId: childId, - parentAgent: parent, - meta: create.meta, - ...(create.seed === undefined ? {} : { seed: create.seed }), - inheritedEventCount: create.inheritedEventCount, - agentOptions: inputs.agentOptions, - signal: inputs.signal, - setup, - }) const activation: Activation = { pool, @@ -655,42 +717,57 @@ export class ContinuableActivationRegistry { childId, parentSession: parent.id, provider, - handle, - inbox: new SubagentInbox(handle.agent), - ancestry: new WeakSet([handle.agent, ...parentLineage]), + driver, + parent, + delivery: inputs.delivery ?? 'parent', + result: Promise.withResolvers(), + released: Promise.withResolvers(), + inbox: new SubagentInbox(driver), + ancestry: new WeakSet(driver.agent === undefined ? parentLineage : [driver.agent, ...parentLineage]), ownedChildren: new Set(), observer, announced: false, poke: Promise.withResolvers(), } + void activation.result.promise.catch(() => undefined) this.resident.set(childId, activation) try { - inputs.signal.throwIfAborted() this.assertAdmitting(parent) + inputs.signal.throwIfAborted() + if (this.ctx.agents.get(parent.id) !== parent) throw new SubagentError('subagent parent is no longer live', 'UNAUTHORIZED') this.acquireOwnership(parent, childId) const wakeOnInboxRemoval = (): void => { this.wake(activation) } - handle.agent.ctx.on('agent/inbox/claimed', wakeOnInboxRemoval) - handle.agent.ctx.on('agent/inbox/discarded', wakeOnInboxRemoval) - observer.start(handle.agent) + driver.onInputRemoved(wakeOnInboxRemoval) + observer.start(driver.agent) } catch (error: unknown) { /* 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 } - this.watchSettlement(activation) return activation } + /** + * Begin settlement observation after initial acceptance is recorded. + * @param activation - accepted activation to observe. + */ + announce(activation: Activation): void { + if (activation.announced) return + activation.announced = true + this.watchSettlement(activation) + } + /** Release an Activation whose start edge was not published. */ private rollbackUnpublished(activation: Activation): Promise { return activation.inbox.close(async () => { try { - await activation.handle.dispose() + await activation.driver.dispose() } finally { this.resident.delete(activation.childId) activation.releaseSlot() this.releaseOwnership(activation.childId) + activation.released.resolve() } }) } @@ -726,8 +803,18 @@ export class ContinuableActivationRegistry { void (async () => { while (true) { const idleObservation = activation.poke - await activation.handle.agent.whenIdle() + await activation.driver.whenIdle() if (activation.inbox.closing !== undefined) return + if (activation.announced && !activation.driver.hasPending) { + try { + activation.result.resolve(activation.driver.capture()) + } catch (error: unknown) { + activation.result.reject(error) + // Teardown captures the terminal failure and releases the activation. + await this.dispose(activation).catch(() => undefined) + return + } + } const readiness = await this.locks.run(activation.childId, () => Promise.resolve( this.settlementState(activation, idleObservation), )) @@ -738,23 +825,17 @@ export class ContinuableActivationRegistry { continue } - const finalSeq = activation.handle.agent.session.seq + const finalSeq = activation.driver.version await this.flushFinalState(activation) const attempt = await this.locks.run(activation.childId, () => { const state = this.settlementState(activation, idleObservation) if (state !== 'ready') return Promise.resolve(state) - if (activation.handle.agent.session.seq !== finalSeq) { + if (activation.driver.version !== finalSeq) { return Promise.resolve('retry') } // The task starts synchronously, so idle ownership and Inbox closure share one turn. let done!: Promise - try { - void activation.handle.agent.runMaintenance(() => { - done = this.dispose(activation, true) - return Promise.resolve() - }) - } catch { - // Another activity won the idle phase after the preceding observation. + if (!activation.driver.closeWhenIdle(() => { done = this.dispose(activation, true) })) { return Promise.resolve('retry') } return Promise.resolve({ done }) @@ -770,7 +851,7 @@ export class ContinuableActivationRegistry { await attempt.done } catch (error: unknown) { this.ctx.logger.warn( - `subagent "${activation.childId}" activation teardown failed: ${errorChain(error)}`, + `subagent "${activation.childId}" activation teardown failed: ${failureMessage(error)}`, ) } return @@ -794,19 +875,14 @@ export class ContinuableActivationRegistry { this.wake(activation) const { childId } = activation const failures: SubagentError[] = [] + const detail = (error: unknown): string => activation.driver.agent === undefined + ? failureMessage(error) + : errorChain(error) if (finalStateFlushed) { - try { - activation.observer.capture(activation.handle.agent) - } catch (error: unknown) { - failures.push(new SubagentError( - `subagent "${childId}" activation teardown failed: ${errorChain(error)}`, - 'ACTIVATION_TEARDOWN_FAILED', - { cause: error }, - )) - } + activation.observer.captureResult(activation.driver.capture()) } else { - activation.handle.agent.cancel({ kind: 'parent' }) - const idle = activation.handle.agent.whenIdle() + activation.driver.cancel('parent') + const idle = activation.driver.whenIdle() const children = [...activation.ownedChildren] .map(child => this.resident.get(child)) .filter((child): child is Activation => child !== undefined) @@ -823,26 +899,26 @@ export class ContinuableActivationRegistry { const reasons = childFailures.filter(reason => reason !== undefined) if (reasons.length > 0) { failures.push(new SubagentError( - `subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`, + `subagent "${childId}" child teardown failed: ${reasons.map(reason => failureMessage(reason)).join('; ')}`, 'ACTIVATION_TEARDOWN_FAILED', )) } await idle await this.flushFinalState(activation) - activation.observer.capture(activation.handle.agent) + activation.observer.captureResult(activation.driver.capture()) } catch (error: unknown) { failures.push(new SubagentError( - `subagent "${childId}" activation teardown failed: ${errorChain(error)}`, + `subagent "${childId}" activation teardown failed: ${detail(error)}`, 'ACTIVATION_TEARDOWN_FAILED', { cause: error }, )) } } try { - await activation.handle.dispose() + await activation.driver.dispose() } catch (error: unknown) { failures.push(new SubagentError( - `subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`, + `subagent "${childId}" activation handle disposal failed: ${detail(error)}`, 'ACTIVATION_TEARDOWN_FAILED', { cause: error }, )) @@ -854,26 +930,44 @@ export class ContinuableActivationRegistry { } else if (failures.length > 1) { failure = new SubagentError( `subagent "${childId}" activation teardown failed at ${failures.length} boundaries: ` - + failures.map(item => errorChain(item)).join('; '), + + failures.map(item => detail(item)).join('; '), 'ACTIVATION_TEARDOWN_FAILED', { cause: new AggregateError(failures) }, ) } this.resident.delete(childId) activation.releaseSlot() + const terminal = activation.observer.terminal(failure) + const result = { ...terminal, output: terminal.output ?? [] } + const captured = activation.observer.terminal(undefined) + activation.result.resolve(result) + if (activation.announced && activation.driver.agent === undefined) { + try { + recordExternalEnd(activation.parent.session, childId, { + ...captured, + output: captured.output ?? [], + stopReason: terminal.stopReason, + }) + } catch (error: unknown) { + failure ??= new SubagentError(`subagent "${childId}" result could not be recorded`, 'ACTIVATION_TEARDOWN_FAILED', { cause: error }) + } + } this.notifySettlement(activation, activation.observer.terminal(failure)) this.releaseOwnership(childId) activation.observer.settle(failure) + activation.released.resolve() if (failure !== undefined) throw failure } /** Tell the durable direct parent how this Activation ended. */ private notifySettlement(activation: Activation, terminal: ActivationTerminal): void { - if (!activation.announced) return + if (!activation.announced || activation.delivery === 'caller') return try { const parent = this.ctx.agents.get(activation.parentSession) - if (parent === undefined) return - const message = createSettlementMessage(activation.childId, terminal) + if (parent !== activation.parent) return + const message = createSettlementMessage( + activation.childId, terminal, activation.driver.agent === undefined, activation.driver.agent !== undefined, + ) if (this.closingTeardownFor(parent) !== undefined) { parent.inject(message) return @@ -889,9 +983,8 @@ export class ContinuableActivationRegistry { /** Request a best-effort final session flush before closing natural-settlement admission. */ private async flushFinalState(activation: Activation): Promise { - const child = activation.handle.agent try { - await child.ctx.sessions.flush(child.session) + await activation.driver.flush() } catch (error: unknown) { this.ctx.logger.warn( `subagent "${activation.childId}" best-effort final session flush failed; ` diff --git a/packages/subagent/subagent/src/continuation-messages.ts b/packages/subagent/subagent/src/continuation-messages.ts index bea8ed439b..9eb6c35329 100644 --- a/packages/subagent/subagent/src/continuation-messages.ts +++ b/packages/subagent/subagent/src/continuation-messages.ts @@ -101,13 +101,16 @@ export function withContinuableReturnGuidance( * the parent's own task vocabulary. * @param childId - the durable child the parent knows by id. * @param stopReason - how the child's last ordinary turn ended. + * @param continuable - whether the child accepts a later message. * @returns the model-facing opening line of the settlement notice. */ -function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopReason']): string { +function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopReason'], continuable: boolean): string { const subject = `Background subagent ${childId}` switch (stopReason) { case 'completed': - return `${subject} finished and will do no further work unless you send it more.` + return continuable + ? `${subject} finished and will do no further work unless you send it more.` + : `${subject} finished. It cannot receive follow-up messages.` case 'aborted': return `${subject} was stopped before it finished.` case 'max-tokens': @@ -130,25 +133,33 @@ function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopR * Build the runtime-owned settlement notice from the child's nonempty closing text. * @param childId - durable child session id named in the notice. * @param terminal - recorded terminal state for the settled Activation. + * @param includeOutput - include the external backend result in the notice. + * @param continuable - whether the child can accept another task. * @returns the durable user-message representation delivered to the parent. */ export function createSettlementMessage( childId: SessionId, terminal: ActivationTerminal, + includeOutput = true, + continuable = true, ): ReturnType { - const summary = settlementSummary(childId, terminal.stopReason) + const summary = settlementSummary(childId, terminal.stopReason, continuable) // Parent providers receive this notice as a user message and may reject // nontext assistant blocks. Keep this conversion local so SDK/UI consumers // retain the complete child output. - const closingText = (terminal.output ?? []).flatMap(block => + const closingText = (includeOutput ? terminal.output ?? [] : []).flatMap(block => block.type === 'text' && block.text.length > 0 ? [block] : [], ) return createUserMessage({ content: [ { type: 'text' as const, text: summary }, - ...closingText.length === 0 + ...!includeOutput ? [] : closingText.length === 0 ? [{ type: 'text' as const, text: 'It left no closing message.' }] : [{ type: 'text' as const, text: 'Its closing message:' }, ...closingText], + ...includeOutput && terminal.structured !== undefined + ? [{ type: 'text' as const, text: `Structured result: ${JSON.stringify(terminal.structured)}` }] : [], + ...includeOutput && terminal.diagnostic !== undefined + ? [{ type: 'text' as const, text: terminal.diagnostic }] : [], ], source: { kind: 'subagent-settled' as const, diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 87fae7096a..f9b505689a 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -1,5 +1,5 @@ /** - * Continuable-subagent orchestration behind `ctx.subagents`: stable child ids, + * Subagent orchestration behind `ctx.subagents`: stable child ids, * descriptor persistence, provider preparation, cold resume, authorization, * and message routing. {@link ContinuableActivationRegistry} owns the mutable * process-local Activation graph and its settlement and disposal lifecycle. @@ -7,8 +7,7 @@ * A continuable child has one durable Session and at most one process-local * Activation. The Agent inbox is the only turn queue, so this manager owns * durable orchestration while the Agent loop owns all turn ordering and - * execution. No continuable path creates a Task or an intermediate - * result-bearing wrapper. + * execution. External drivers retain one backend execution per activation. * * @module @deepseek-ai/dsh-subagent */ @@ -40,15 +39,18 @@ import { import { assertSubagentMaxDepth } from './depth.ts' import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' import { establishCatalogChild } from './catalog.ts' +import { recordExternalStart } from './external-records.ts' import { SubagentError } from './error.ts' import { isAdjacentAgentSendMessageTool } from './internal.ts' import type { ActivationObserver } from './lifecycle.ts' import type { ContinuableCreateRequest, ContinuableCreateSpec, - ContinuableStart, - ContinuableStartSpec, SubagentInterruptAuthority, + SubagentActivationSpec, + SubagentActivation, + ResolvedSubagentStartRequest, + SubagentRun, SubagentSendMessageOptions, } from './types.ts' @@ -67,6 +69,7 @@ type ChildDeliveryOptions = /** Package-private hooks supplied by the owning service. */ interface ContinuationHost { + startExternal(name: string, request: ResolvedSubagentStartRequest): Promise /** Resolve one provider's detached continuable-creation contribution. */ prepareContinuable(name: string, request: ContinuableCreateRequest): Promise /** Build the lifecycle observer for one Activation residency epoch. */ @@ -74,10 +77,8 @@ interface ContinuationHost { } /** - * 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. + * Activation orchestration behind `ctx.subagents`: local conversations and + * external executions share admission, ownership, interruption, and release. */ export class SubagentContinuationManager { private readonly activations: ContinuableActivationRegistry @@ -101,11 +102,11 @@ export class SubagentContinuationManager { * @param spec - provider, delegation request, and caller cancellation. * @returns the durable child id and accepted initial prompt message id. */ - async startContinuable(spec: ContinuableStartSpec): Promise { + async startContinuable(spec: SubagentActivationSpec): Promise { const request = spec.request const parent = request.parent this.activations.assertAdmitting(parent) - const persistence = this.requirePersistence() + const persistence = spec.delivery === 'caller' ? this.ctx.get('sessionPersistence') : this.requirePersistence() assertSubagentMaxDepth(request.maxDepth) const childId = spec.childId ?? brandString(randomUUID()) this.activations.assertChildIdAvailable(childId) @@ -145,12 +146,13 @@ export class SubagentContinuationManager { const inheritedEventCount = SessionLogOffset(prepared.seed?.length ?? 0) const seed = prepared.seed + let established!: Activation const messageId = await this.activations.locks.run(childId, async () => { spec.signal.throwIfAborted() this.activations.assertAdmitting(parent) this.activations.assertChildIdAvailable(childId) if (spec.childId !== undefined) { - const persisted = await persistence.stat(childId, { signal: spec.signal }) + const persisted = await persistence?.stat(childId, { signal: spec.signal }) spec.signal.throwIfAborted() this.activations.assertAdmitting(parent) this.activations.assertChildIdAvailable(childId) @@ -172,11 +174,15 @@ export class SubagentContinuationManager { agentOptions, composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, + delivery: spec.delivery, + outputSchema: spec.request.outputSchema, }) - const childHeader = activation.handle.agent.session.header + established = activation + const child = this.localAgent(activation) + const childHeader = child.session.header return await this.submitMaterialized( activation, - isAdjacentAgentSendMessageTool(this.ctx.get('tools')?.get('send_message', activation.handle.agent)) + spec.delivery !== 'caller' && isAdjacentAgentSendMessageTool(this.ctx.get('tools')?.get('send_message', child)) ? withContinuableReturnGuidance(parent.id, request.prompt) : request.prompt, { source: { kind: 'user' }, signal: spec.signal, delivery: 'queue' }, @@ -184,13 +190,76 @@ export class SubagentContinuationManager { () => { establishCatalogChild(parent.session, childHeader, descriptor) }, ) }) - return { childId, messageId } + return { ...this.receipt(established), childId, messageId } } catch (error: unknown) { releaseHold() throw error } } + /** + * Start an external backend under activation ownership. + * @param spec - task, provider, cancellation, and delivery policy. + * @returns the accepted activation and its result/disposal capabilities. + */ + async startExternal(spec: SubagentActivationSpec): Promise { + const parent = spec.request.parent + this.activations.assertAdmitting(parent) + const pendingId = brandString(randomUUID()) + const releaseHold = this.activations.holdOwnership(parent, pendingId) + const descriptor = snapshotSubagentDescriptor({ mode: 'one-shot', provider: spec.provider, label: spec.label }) + let activation: Activation | undefined + try { + activation = await this.activations.materialize({ + childId: pendingId, + provider: spec.provider, + parent, + agentOptions: {}, + composition: {}, + signal: spec.signal, + delivery: spec.delivery, + external: signal => this.host.startExternal(spec.provider, { + ...spec.request, label: spec.label, descriptor, signal, + }), + }) + this.activations.assertAdmitting(parent) + this.activations.authorizeLineage(parent, activation.childId, activation.parentSession) + spec.signal.throwIfAborted() + recordExternalStart(parent.session, activation.childId, spec.provider, spec.label) + this.activations.announce(activation) + return this.receipt(activation) + } catch (error: unknown) { + if (activation !== undefined) { + try { + await this.activations.dispose(activation) + } catch (cleanupError: unknown) { + this.ctx.logger.warn(`subagent "${activation.childId}" admission rollback failed: ${(cleanupError as SubagentError).message}`) + } + } + throw error + } finally { + releaseHold() + } + } + + /** + * Whether the child owns an activation in this process. + * @param childId - child identity to inspect. + * @returns whether an activation is resident. + */ + isActive(childId: SessionId): boolean { + return this.activations.get(childId) !== undefined + } + + /** Return capabilities bound to an exact activation instead of a future same-id instance. */ + private receipt(activation: Activation): SubagentActivation { + return { + childId: activation.childId, + result: activation.result.promise, + dispose: () => this.activations.dispose(activation), + } + } + /** * Deliver one model-authored message to a direct continuable child or to the * sender's direct parent. A missing direct child cold-resumes through the @@ -216,7 +285,7 @@ export class SubagentContinuationManager { this.activations.assertAdmitting(sender) const senderActivation = this.activations.get(sender.id) if (senderActivation !== undefined - && senderActivation.handle.agent === sender + && senderActivation.driver.agent === sender && senderActivation.parentSession === targetId) { options.signal.throwIfAborted() return this.sendToParent(senderActivation, sender, content) @@ -299,6 +368,7 @@ export class SubagentContinuationManager { const live = await this.activations.locks.run(childId, async () => { const activation = this.activations.get(childId) if (activation === undefined) return this.coldResume(parent, childId, content, options) + this.localAgent(activation) const disposal = activation.inbox.closing /* v8 ignore next 3 -- the send-versus-dispose cutoff needs a delivery to * observe the transaction inside the same critical section that opened it. */ @@ -306,14 +376,14 @@ export class SubagentContinuationManager { return disposal.then(() => undefined, () => undefined) } if (contentHasImage(content)) { - await this.assertImageCapable(activation.handle.agent, options.signal) + await this.assertImageCapable(this.localAgent(activation), options.signal) if (activation.inbox.closing !== undefined) { await Promise.allSettled([activation.inbox.closing]) return undefined } } const messageId = this.submitAdmitted(activation, content, options, parent) - activation.announced = true + this.activations.announce(activation) return messageId }) /* v8 ignore start -- only a delivery that lost the disposal cutoff retries. */ @@ -350,7 +420,7 @@ export class SubagentContinuationManager { ) } const parent = this.ctx.agents.get(activation.parentSession) - if (parent === undefined) { + if (parent !== activation.parent) { throw new SubagentError( 'direct parent is not live; the message was not delivered', 'PARENT_UNAVAILABLE', @@ -377,14 +447,23 @@ export class SubagentContinuationManager { } } + /** + * Wait for the parent's currently owned child work to release its resources. + * @param parent - exact live parent whose child work is observed. + * @returns whether any child work was observed. + */ + waitForChildren(parent: Agent): Promise { + return this.activations.waitForChildren(parent) + } + /** Close manager-wide admission and release every live Activation. */ async drain(): Promise { await this.activations.drain() } /** - * Stop only the continuable descendants of exact live host-owned parents. - * @param parents - exact live roots whose continuable descendants must stop. + * Stop the managed descendants of exact live host-owned parents. + * @param parents - exact live roots whose managed descendants must stop. */ async drainDescendants(parents: readonly Agent[]): Promise { await this.activations.drainDescendants(parents) @@ -455,6 +534,15 @@ export class SubagentContinuationManager { return await this.submitMaterialized(activation, content, options, parent) } + /** Resolve the local inbox owner or reject follow-up input to an external execution. */ + private localAgent(activation: Activation): Agent { + const child = activation.driver.agent + if (child === undefined) { + throw new SubagentError(`subagent "${activation.childId}" does not accept follow-up input`, 'NOT_CONTINUABLE') + } + return child + } + /** Admit a materialized child, commit its creation fact, and release it on failure. */ private async submitMaterialized( activation: Activation, @@ -465,14 +553,14 @@ export class SubagentContinuationManager { ): Promise { try { if (contentHasImage(content)) { - await this.assertImageCapable(activation.handle.agent, options.signal) + await this.assertImageCapable(this.localAgent(activation), options.signal) if (activation.inbox.closing !== undefined) { throw new SubagentError(`subagent "${activation.childId}" is closing`, 'ACTIVATION_CLOSING') } } const messageId = this.submitAdmitted(activation, content, options, parent) commit?.() - activation.announced = true + this.activations.announce(activation) return messageId } catch (error: unknown) { try { diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts index 0325da892f..a0105cf1ce 100644 --- a/packages/subagent/subagent/src/control-types.ts +++ b/packages/subagent/subagent/src/control-types.ts @@ -48,14 +48,23 @@ export type SubagentListEntry = readonly hasChildren: boolean } & ( | { - /** A terminal one-shot child. */ + /** A historical one-shot child with its own Session. */ readonly mode: 'one-shot' + readonly external?: undefined /** Optional durable creation label from the child's descriptor. */ readonly label?: string } + | { + /** One execution whose records belong to the direct parent's log. */ + readonly mode: 'one-shot' + readonly external: true + /** Label required by the parent's external execution record. */ + readonly label: string + } | { /** A resumable conversation. */ readonly mode: 'continuable' + readonly external?: undefined /** Durable creation label from the child's descriptor. */ readonly label: string } diff --git a/packages/subagent/subagent/src/control.ts b/packages/subagent/subagent/src/control.ts index 54e4057513..bdbda67e5b 100644 --- a/packages/subagent/subagent/src/control.ts +++ b/packages/subagent/subagent/src/control.ts @@ -49,9 +49,9 @@ export function validateControlRequest( /** * Project one durable listing onto the catalog view, replacing each row's - * store-derived activity with the live Agent driver's status and reporting - * whether the exact parent Agent is live. Without an Agent registry no driver - * runs at all, so every row is inactive and the parent is unavailable. + * store-derived local activity with the live Agent driver's status and reporting + * whether the exact parent Agent is live. External activity is sampled by the + * subagent manager and is preserved because external children have no Agent. * @param ctx - Host context that may carry the Agent registry. * @param parentSessionId - the listed parent. * @param entries - the durable direct-child listing. @@ -64,7 +64,7 @@ export function catalogView( ): SubagentCatalog { const agents = ctx.get('agents') return { - entries: entries.map((entry): SubagentListEntry => entry.kind === 'child' + entries: entries.map((entry): SubagentListEntry => entry.kind === 'child' && entry.external !== true ? { ...entry, activity: agents?.get(entry.id)?.status === 'running' ? 'running' : 'inactive' } : entry), parentAvailable: agents?.get(parentSessionId) !== undefined, diff --git a/packages/subagent/subagent/src/external-records.ts b/packages/subagent/subagent/src/external-records.ts new file mode 100644 index 0000000000..aac1441888 --- /dev/null +++ b/packages/subagent/subagent/src/external-records.ts @@ -0,0 +1,185 @@ +/** + * Parent-owned execution records for external subagents without child Sessions. + * The projection retains creation and terminal results independently of notices. + * + * @module @deepseek-ai/dsh-subagent/external-records + */ + +import { z } from 'zod' +import { appendChunkedList, chunkedListSchema, iterateChunkedList } from '@deepseek-ai/dsh-chunked-list' +import type { ChunkedList } from '@deepseek-ai/dsh-chunked-list' +import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import type { SubagentResult } from './types.ts' + +/** Durable start of an external execution owned by the recording parent. */ +export interface ExternalSubagentStart { + readonly version: 0 + readonly childId: SessionId + readonly provider: string + readonly label: string +} + +/** Tagged JSON output retained without dropping product-specific content fields. */ +export interface ExternalSubagentContent { + readonly type: string + readonly [key: string]: JsonValue +} + +/** Serializable terminal result available from the parent's log and projection. */ +export interface ExternalSubagentResult { + readonly output: ExternalSubagentContent[] + readonly structured?: JsonValue | undefined + readonly diagnostic?: string | undefined + readonly stopReason: string +} + +/** Terminal result retained after the external execution releases its process. */ +export interface ExternalSubagentEnd { + readonly version: 0 + readonly childId: SessionId + readonly result: ExternalSubagentResult +} + +/** Parent-owned external execution, including its terminal result when recorded. */ +export interface ExternalSubagentRecord { + readonly childId: SessionId + readonly provider: string + readonly label: string + readonly createdAt: number + readonly result?: ExternalSubagentResult | undefined +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * An external child accepted its initial task without creating a DSH Session. + * @param data - parent-owned execution identity and creation label. + */ + 'subagent/external-start': ExternalSubagentStart + /** + * An external child settled with its complete retained result. + * @param data - execution identity and terminal result. + */ + 'subagent/external-end': ExternalSubagentEnd + } +} + +type ExternalFact = + | { readonly kind: 'start'; readonly record: ExternalSubagentRecord } + | { readonly kind: 'end'; readonly childId: SessionId; readonly result: ExternalSubagentResult } + +/** Serializable own-event execution state for a parent Session. */ +export interface ExternalSubagentState { + readonly inheritedEventCount: SessionLogOffset + readonly head?: ChunkedList | undefined +} + +const childIdSchema = z.string().min(1).transform(SessionId) +// Content tags are merge-extensible. Retain tagged JSON blocks without +// dropping fields owned by product-specific content consumers. +const resultSchema: z.ZodType = z.object({ + output: z.array(z.object({ type: z.string().min(1) }).catchall(z.json())), + structured: z.json().optional(), + diagnostic: z.string().optional(), + stopReason: z.string().min(1), +}).strict() +const startSchema = z.object({ + version: z.literal(0), + childId: childIdSchema, + provider: z.string().min(1), + label: z.string(), +}).strict() +const endSchema = z.object({ + version: z.literal(0), + childId: childIdSchema, + result: resultSchema, +}).strict() +const recordSchema: z.ZodType = z.object({ + childId: childIdSchema, + provider: z.string().min(1), + label: z.string(), + createdAt: z.number().int().nonnegative(), + result: resultSchema.optional(), +}).strict() +const factSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('start'), record: recordSchema }).strict(), + z.object({ kind: z.literal('end'), childId: childIdSchema, result: resultSchema }).strict(), +]) +const stateSchema: z.ZodType = z.object({ + inheritedEventCount: z.number().int().nonnegative().transform(SessionLogOffset), + head: chunkedListSchema(factSchema).optional(), +}).strict() + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + subagentExternal: ExternalSubagentState + } + interface SessionProjectionMap { + /** External children whose execution records belong to this parent. */ + subagentExternal: ExternalSubagentRecord[] + } +} + +/** Materialize execution state in creation order, rejecting unpaired terminal facts. */ +function externalRecords(state: ExternalSubagentState): ExternalSubagentRecord[] { + const records = new Map() + for (const fact of iterateChunkedList(state.head)) { + if (fact.kind === 'start') { + if (records.has(fact.record.childId)) throw new Error('duplicate external subagent start') + records.set(fact.record.childId, fact.record) + } else { + const record = records.get(fact.childId) + if (record === undefined || record.result !== undefined) { + throw new Error('external subagent end has no unsettled start') + } + records.set(fact.childId, { ...record, result: fact.result }) + } + } + return [...records.values()] +} + +/** Fold external execution records, excluding facts inherited from another parent. */ +export const externalSubagentProjectionDefinition = { + key: 'subagentExternal', + stateSchema, + init: (_header: SessionHeader, inheritedEventCount: SessionLogOffset) => ({ inheritedEventCount }), + apply: (state, event: SessionEvent) => { + if (event.seq < state.inheritedEventCount) return state + let fact: ExternalFact + if (event.type === 'subagent/external-start') { + const { version: _version, ...record } = startSchema.parse(event.data) + fact = { kind: 'start', record: { ...record, createdAt: event.time } } + } else if (event.type === 'subagent/external-end') { + const { version: _version, ...end } = endSchema.parse(event.data) + fact = { kind: 'end', ...end } + } else return state + return { ...state, head: appendChunkedList(state.head, fact) } + }, + stateVersion: 1, + wire: { viewSchema: z.array(recordSchema), view: externalRecords }, +} satisfies ProjectionDefinition<'subagentExternal', ExternalSubagentState> + +/** + * Record an accepted external execution in its direct parent's log. + * @param parent - parent Session owning the external task. + * @param childId - stable external child identity; no child Session is required. + * @param provider - registered backend that accepted the task. + * @param label - creation label retained for discovery. + */ +export function recordExternalStart(parent: Session, childId: SessionId, provider: string, label: string): void { + parent.append('subagent/external-start', { version: 0, childId, provider, label }) +} + +/** + * Record the external execution's terminal outcome independently of parent notifications. + * @param parent - same parent that recorded the execution start. + * @param childId - external child identity from that start. + * @param result - complete settled output, structured value, diagnostic, and stop reason; rejects non-JSON values. + */ +export function recordExternalEnd(parent: Session, childId: SessionId, result: SubagentResult): void { + const persisted = resultSchema.parse(result) + parent.append('subagent/external-end', { version: 0, childId, result: persisted }) +} diff --git a/packages/subagent/subagent/src/inbox.ts b/packages/subagent/subagent/src/inbox.ts index e610db2014..55fb427ceb 100644 --- a/packages/subagent/subagent/src/inbox.ts +++ b/packages/subagent/subagent/src/inbox.ts @@ -1,10 +1,10 @@ /** - * Activation-local admission around one continuable subagent's Agent inbox. + * Admission and closure for one activation execution driver. * * @module @deepseek-ai/dsh-subagent/inbox */ -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ActivationDriver } from './activation-driver.ts' import type { UserMessage } from '@deepseek-ai/dsh-session' import type { SubagentPromptRequest } from './control-types.ts' import { SubagentError } from './error.ts' @@ -12,15 +12,15 @@ import { SubagentError } from './error.ts' /** One Agent inbox destination, as the wire request selects it. */ export type SubagentDelivery = SubagentPromptRequest['delivery'] -/** Delegate Queue and Steer to one live Agent until its Activation starts closing. */ +/** Admit driver input until the activation starts closing. */ export class SubagentInbox { private closingPromise: Promise | undefined /** - * Wrap one live continuable Agent. - * @param agent - the Agent whose inbox receives accepted deliveries. + * Wrap one activation driver. + * @param driver - execution receiving accepted input. */ - constructor(private readonly agent: Agent) {} + constructor(private readonly driver: ActivationDriver) {} /** * Read the Activation's close transaction. @@ -35,7 +35,7 @@ export class SubagentInbox { * @returns whether either Agent inbox destination is non-empty. */ get hasPending(): boolean { - return this.agent.inbox.nextTurn.length > 0 || this.agent.inbox.nextStep.length > 0 + return this.driver.hasPending } /** @@ -46,12 +46,11 @@ export class SubagentInbox { deliver(message: UserMessage, delivery: SubagentDelivery): void { if (this.closingPromise !== undefined) { throw new SubagentError( - `subagent "${this.agent.id}" activation is being disposed; the message was not accepted`, + 'subagent activation is being disposed; the message was not accepted', 'ACTIVATION_CLOSING', ) } - if (delivery === 'steer') this.agent.steer(message) - else this.agent.followup(message) + this.driver.deliver(message, delivery) } /** diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 5d2941be6a..3032997bf7 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -11,20 +11,12 @@ * (`@deepseek-ai/dsh-subagent-spawn-in-process`, `-fork`, `-acp`) and the model-facing * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. * - * Public operations express caller intent: `start` returns one published owned - * one-shot run, `startContinuable` establishes a durable continuable child, and - * `sendMessage` steers between adjacent Agents without exposing whether a 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. Child and descendant - * discovery read the live session store and optional session persistence - * directly and do not require that continuation runtime. - * - * Same-process providers are trusted typed collaborators. Requests, provider - * descriptors, results, and lifecycle payloads are borrowed immutable values; - * serialization and hostile-input validation belong at real process, worker, - * persistence, and model boundaries. + * `startActivation` establishes every child under the activation manager, which + * owns execution, result delivery, and resource release. Local providers seed + * resumable Agents; external providers expose one execution through a shared + * adapter. `sendMessage` steers between adjacent local Agents without exposing + * whether a child is resident. Discovery combines real child Sessions with + * parent-owned records for external executions. * * @module @deepseek-ai/dsh-subagent */ @@ -54,13 +46,11 @@ import type { import type { ContinuableCreateRequest, ContinuableCreateSpec, - ContinuableStart, - ContinuableStartSpec, - ResolvedSubagentStartRequest, SubagentCapabilities, + SubagentActivation, + SubagentActivationSpec, SubagentInterruptAuthority, SubagentProvider, - SubagentRun, SubagentRunEndInfo, SubagentRunInfo, SubagentSendMessageOptions, @@ -68,15 +58,15 @@ import type { } from './types.ts' import { SubagentError } from './error.ts' import { assertSubagentMaxDepth } from './depth.ts' -import { createActivationObserver, createLifecycleEmitter, observeRun } from './lifecycle.ts' +import { createActivationObserver, createLifecycleEmitter } from './lifecycle.ts' import type { ActivationObserver, LifecycleEmitter } from './lifecycle.ts' import SubagentContinuationManager from './continuation.ts' import type { SubagentDelivery } from './inbox.ts' import { listChildren as listSubagentChildren, listDescendants as listSubagentDescendants } from './list-children.ts' import type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts' -import { snapshotSubagentDescriptor } from './descriptor.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' -import { establishCatalogChild, subagentCatalogProjectionDefinition } from './catalog.ts' +import { subagentCatalogProjectionDefinition } from './catalog.ts' +import { externalSubagentProjectionDefinition } from './external-records.ts' import { deliverSubagentPrompt } from './internal.ts' export type {} from './catalog.ts' @@ -86,10 +76,10 @@ export { SubagentRunId } from './types.ts' export type { ContinuableCreateRequest, ContinuableCreateSpec, - ContinuableStart, - ContinuableStartSpec, ResolvedSubagentStartRequest, SubagentCapabilities, + SubagentActivation, + SubagentActivationSpec, SubagentInterruptAuthority, SubagentProvider, SubagentResult, @@ -112,8 +102,8 @@ export type { SubagentDescriptorData, SubagentDescriptorInput, } from './descriptor.ts' +export { STRUCTURED_OUTPUT_TOOL } from './structured.ts' export { SubagentError } from './error.ts' -export { settleRun } from './run-settlement.ts' export { assertSubagentMaxDepth, delegationDepthOf } from './depth.ts' export { appendDelegatedPolicyOverrides, @@ -194,7 +184,7 @@ export interface Config { maxDepth?: number } -/** Named provider registry with one-shot runs, durable discovery, and continuable-child operations. */ +/** Named provider registry with managed activations, durable discovery, and local child messaging. */ export class SubagentRuntime extends TypertRemoteService { static Config: z = z.object({ maxDepth: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(1), @@ -224,6 +214,10 @@ export class SubagentRuntime extends TypertRemoteService { this.emitLifecycle = createLifecycleEmitter(this.ctx, parent => scopeTarget(this, parent)) ctx.inject(['agents'], (childCtx: Context) => { const manager = new SubagentContinuationManager(childCtx, { + startExternal: (name, request) => { + const provider = this.expectProvider(name) as SubagentProvider & Required> + return provider.start(request) + }, prepareContinuable: (name, request) => this.prepareContinuable(name, request), observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent), }, () => (this.settingsSource() as Required).maxActiveSubagents) @@ -235,6 +229,7 @@ export class SubagentRuntime extends TypertRemoteService { }) ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.register(subagentCatalogProjectionDefinition) + projectionCtx.sessionProjections.register(externalSubagentProjectionDefinition) projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition) projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition) }) @@ -251,16 +246,34 @@ export class SubagentRuntime extends TypertRemoteService { } /** - * 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. + * Establish a managed child with backend-specific execution capabilities. + * @param spec - task, backend, and result recipient. + * @returns identities, execution result, and exact-activation disposal. */ - async startContinuable(spec: ContinuableStartSpec): Promise { - return this.requireContinuations().startContinuable(spec) + async startActivation(spec: SubagentActivationSpec): Promise { + const provider = this.expectProvider(spec.provider) + if (spec.childId !== undefined && provider.prepareContinuable === undefined) { + throw new SubagentError('reserved child ids require a local backend', 'UNSUPPORTED_CAPABILITY') + } + assertSubagentMaxDepth(spec.request.maxDepth) + this.assertCapabilities(provider, { ...spec.request, signal: spec.signal }) + if (this.ctx.get('agents')?.get(spec.request.parent.id) !== spec.request.parent) { + throw new SubagentError('subagent creation requires the exact live parent agent', 'UNAUTHORIZED') + } + if (spec.request.outputSchema !== undefined) assertObjectJsonSchema(spec.request.outputSchema) + const manager = this.requireContinuations() + return provider.prepareContinuable === undefined + ? manager.startExternal(spec) + : manager.startContinuable(spec) + } + + /** + * Wait for this live parent's currently owned child work without cancelling it. + * @param parent - the exact parent whose descendants should settle. + * @returns whether any work was observed; hosts recheck parent idle after true. + */ + async waitForChildren(parent: Agent): Promise { + return this.continuations?.waitForChildren(parent) ?? false } /** @@ -312,13 +325,13 @@ export class SubagentRuntime extends TypertRemoteService { } /** - * Interrupt one live continuable child's current turn under a human parent + * Interrupt one live child's current execution under a human parent * address or an exact live ancestor Agent. Fire-and-return: the cancel * signal is issued before this returns, but the target may keep running * until it observes the signal. Unclaimed pending inbox work, the Activation, * and published descendants are preserved; claimed work is not requeued. * Once the interrupted driver is idle, a waking send resumes the parked FIFO - * queue. An absent target — including a one-shot or unknown id — + * queue. External backends stop their single execution. An absent target * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. * @param targetSessionId - the durable child session id to interrupt. @@ -331,40 +344,40 @@ export class SubagentRuntime extends TypertRemoteService { } /** - * Close continuable admission below exact live parent Agents, stop only their + * Close subagent 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`. + * @returns once every retained descendant activation released its driver. * @throws an aggregate error after all branches settle when any failed. */ - async drainContinuableDescendants(parents: readonly Agent[]): Promise { + async drainDescendants(parents: readonly Agent[]): Promise { const manager = this.continuations - // Absent continuation services means nothing was ever materialized. + // An absent activation manager cannot own materialized children. if (manager === undefined) return await manager.drainDescendants(parents) } /** - * Release selected resident continuable direct children of one exact live + * Release selected resident direct children of one exact live * parent. Other children of the same parent remain admitted and resident. * Absent targets and a manager-less composition are accepted no-ops. * @param parent - exact live direct parent authorizing the selected release. * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected Activation released its `AgentHandle`. + * @returns once every selected activation released its driver. * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a * different parent or the supplied parent identity is stale. */ - async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise { + async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise { const manager = this.continuations if (manager === undefined) return await manager.drainChildren(parent, childIds) } /** - * Enumerate the parent's direct session-backed subagents without loading or + * Enumerate the parent's direct local and external subagents without loading or * resuming an Agent. The Session query service supplies one live-preferred * corpus and shared point observations; the projection cache supplies * immutable descriptor hits without opening cold logs. The registered @@ -380,8 +393,8 @@ export class SubagentRuntime extends TypertRemoteService { * @throws {@link SubagentError} when the projection registry or the session * store is not mounted, or the caller cancels the listing. */ - listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise { - return listSubagentChildren(this.ctx, parentSessionId, signal) + async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise { + return (await listSubagentChildren(this.ctx, parentSessionId, signal)).map(entry => this.withActivity(entry)) } /** @@ -399,8 +412,13 @@ export class SubagentRuntime extends TypertRemoteService { * stable pre-order. * @throws {@link SubagentError} under the same conditions as {@link listChildren}. */ - listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise { - return listSubagentDescendants(this.ctx, rootSessionId, signal) + async listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise { + return (await listSubagentDescendants(this.ctx, rootSessionId, signal)).map(entry => this.withActivity(entry)) + } + + private withActivity(entry: T): T { + if (entry.kind !== 'child' || !entry.external) return entry + return { ...entry, activity: this.continuations?.isActive(entry.id) ? 'running' : 'inactive' } } /** @@ -576,50 +594,6 @@ export class SubagentRuntime extends TypertRemoteService { return [...this.providers.keys()] } - /** - * Establish a published child on the named provider. Capability and semantic - * checks run before delegation. Provider ownership lasts until its promise - * fulfills; a rejection therefore has no run for the caller to dispose and - * emits no run lifecycle events. Post-publication turn and infrastructure - * failures settle through the returned run. - * A catalog append failure disposes the run and handles its result rejection; - * the caller receives the catalog error even if disposal also fails. - * @param name - the provider to use. - * @param request - child label, prompt, parent, signal, and optional capabilities. - * @returns the published 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) - const descriptor = snapshotSubagentDescriptor({ - mode: 'one-shot', - provider: name, - ...request.label !== undefined ? { label: request.label } : {}, - }) - const resolved: ResolvedSubagentStartRequest = { ...request, descriptor } - const run = await provider.start(resolved) - const child = run.localAgent?.session - if (child !== undefined) { - try { - establishCatalogChild(request.parent.session, child.header, descriptor) - } catch (error: unknown) { - // No caller receives this run; the catalog error owns the failed start. - void run.result.catch(() => undefined) - try { - await run.dispose() - } catch (cleanupError: unknown) { - this.ctx.logger.warn( - `subagent: disposal after catalog append failure also failed: ${String(cleanupError)}`, - ) - } - throw error - } - } - return observeRun(this.emitLifecycle, name, request.parent, run) - } - /** * Resolve one provider's detached continuable-creation contribution. Method * presence on the provider IS the capability, so a provider without it is @@ -629,14 +603,7 @@ export class SubagentRuntime extends TypertRemoteService { name: string, request: ContinuableCreateRequest, ): Promise { - const provider = this.expectProvider(name) - if (provider.prepareContinuable === undefined) { - throw new SubagentError( - `subagent provider "${provider.name}" does not support continuable children ` - + '(no prepareContinuable capability)', - 'UNSUPPORTED_CAPABILITY', - ) - } + const provider = this.expectProvider(name) as SubagentProvider & Required> return provider.prepareContinuable(request) } diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index c2ef451920..c7755a7c96 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -44,7 +44,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (eventName === 'subagent/start') { const info = args[0] as SubagentRunInfo // Provider availability is an admission-time relationship. A published - // one-shot run may outlive provider removal, and a cold-resumed Activation + // external run may outlive provider removal, and a cold-resumed Activation // records the initial provider name 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') diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index adeba106f6..238952049a 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -1,6 +1,5 @@ /** - * Lifecycle-edge publication for both subagent shapes: the contained emitter, - * the one-shot run observer, and the continuable Activation observer. + * Lifecycle-edge publication for managed subagent activations. * * The public payload contracts ({@link SubagentRunInfo}, * {@link SubagentRunEndInfo}) live in `./types.ts` with the rest of the seam's @@ -19,11 +18,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { foldConsumedWork } from '@deepseek-ai/dsh-agent' -import { SessionLogOffset } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' -import { finalAssistantOutput } from './assistant-output.ts' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { SubagentRunId } from './types.ts' -import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts' +import type { SubagentResult, SubagentRunEndInfo, SubagentRunInfo } from './types.ts' /** * How one Activation's residency epoch ended, as both the terminal lifecycle @@ -34,11 +31,14 @@ export interface ActivationTerminal { readonly stopReason: SubagentResult['stopReason'] /** The epoch's final assistant content, absent when it produced none or failed. */ readonly output?: ContentBlock[] + /** Structured result retained for program consumers. */ + readonly structured?: unknown + /** Backend-authored failure detail. */ + readonly diagnostic?: string } /** - * Lifecycle observer for one Activation's residency epoch, so continuable - * children emit the same start/end pair as one-shot runs. Package-private: the + * Lifecycle observer for one activation's residency epoch. Package-private: the * continuation manager is the only consumer, and its call ordering is an * in-package contract rather than a published extension point. */ @@ -47,14 +47,9 @@ 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 + start(child?: Agent): void + /** Record a backend-owned terminal result. */ + captureResult(result: SubagentResult): void /** * Resolve the terminal facts {@link settle} will publish, without publishing * them. The manager's parent delivery must run before the ownership release @@ -124,49 +119,7 @@ export function createLifecycleEmitter( } /** - * 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 published 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, - // Omit the field when no output exists, matching continuable epochs. - ...result.output.length === 0 ? {} : { 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. + * Build the observer for one local or external activation's residency epoch. 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. @@ -179,13 +132,7 @@ export function createActivationObserver( 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: SessionLogOffsetType = SessionLogOffset(0) - // Assigned by `capture()`, which the disposal path always runs before - // `settle()`; a resident epoch therefore always has its facts by then. + const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: false } let captured: ActivationTerminal = { stopReason: 'completed' } // Teardown failure overrides the epoch's own outcome and withholds its // output: an answer this harness could not durably release is not a result. @@ -193,18 +140,13 @@ export function createActivationObserver( ? captured : { stopReason: 'error' } return { - start: (child: Agent): void => { - boundary = child.session.seq + start: (child?: Agent): void => { + identity.local = child !== undefined emit('subagent/start', identity, parent) }, - capture: (child: Agent): void => { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. - const own = child.session.snapshotEvents(boundary) - const output = finalAssistantOutput(own) - captured = { - stopReason: epochStopReason(own), - ...output === undefined ? {} : { output }, - } + captureResult: (result: SubagentResult): void => { + const { output, ...rest } = result + captured = { ...rest, ...output.length === 0 ? {} : { output } } }, terminal, settle: (failure: unknown): void => { @@ -234,7 +176,7 @@ export function createActivationObserver( * @returns its terminal stop reason; `completed` only for an epoch that both * closed cleanly and had nothing left to run. */ -function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopReason'] { +export function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopReason'] { const { end, droppedUnrun } = foldConsumedWork(events) switch (end?.data.reason.kind) { case 'max-tokens': diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 1d2c58ba6d..fef16a079d 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -7,9 +7,10 @@ * an unseeded durable projection-cache row, and one shared Session observation * otherwise. A seeded header deliberately lacks its exact inherited cut, so * it takes the body-bearing observation path before classifying an identity. - * The projection fold is the single classification - * authority — this module parses no descriptor - * itself. Absent persistence, enumeration is live-only: a cold child is + * External executions come from the direct parent's `subagentExternal` + * projection and need no child Session. Projection folds own interpretation; + * this module parses neither descriptors nor execution events. Absent + * persistence, enumeration is live-only: a cold child is * unreachable for resume anyway, so its absence is capability absence, not an * error. The module owns no catalog state and does not consult Activation, * Agent-registry, continuation-manager, or provider state. @@ -26,6 +27,7 @@ import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-se import type { SubagentListEntry } from './control-types.ts' import { SubagentError } from './error.ts' import type { SubagentIdentityProjection } from './projection-types.ts' +import type { ExternalSubagentRecord } from './external-records.ts' export type { SubagentListEntry } from './control-types.ts' @@ -56,8 +58,11 @@ interface ListingRuntime { readonly cache: SessionProjectionCache | undefined readonly corpus: ReadonlyMap readonly subagentParents: ReadonlySet + readonly observations: Map> } +type ListingObservation = Pick + interface PositionedCandidate { readonly record: CorpusRecord readonly parentId: SessionId @@ -91,7 +96,13 @@ export async function listChildren( && record.header.origin === 'subagent') .sort(compareCorpusRecords) const rows = await resolveCandidateRows(candidates, listing, signal) - return rows.filter((row): row is SubagentListEntry => row !== undefined) + const external = await resolveExternalRecords(parentSessionId, listing, signal, true) + const times = new Map(candidates.map(candidate => [candidate.header.id, candidate.header.createdAt])) + for (const record of external) times.set(record.childId, record.createdAt) + return [ + ...rows.filter((row): row is SubagentListEntry => row !== undefined), + ...external.map(externalRow), + ].sort((a, b) => (times.get(a.id) as number) - (times.get(b.id) as number) || a.id.localeCompare(b.id)) } /** @@ -113,7 +124,16 @@ export async function listDescendants( signal?: AbortSignal, ): Promise { const listing = await prepareListing(ctx, signal) - const positioned = descendantCandidates(listing.corpus, rootSessionId) + const tree = descendantCandidates(listing.corpus, rootSessionId) + const externalByParent = new Map() + const parents = [rootSessionId, ...tree.map(position => position.record.header.id)] + const queue = [...parents] + await Promise.all(Array.from({ length: Math.min(COLD_READ_CONCURRENCY, queue.length) }, async () => { + for (let parent = queue.shift(); parent !== undefined; parent = queue.shift()) { + externalByParent.set(parent, await resolveExternalRecords(parent, listing, signal, parent === rootSessionId)) + } + })) + const positioned = tree.filter(position => position.record.header.origin === 'subagent') const rows = await resolveCandidateRows( positioned.map(candidate => candidate.record), listing, @@ -123,10 +143,21 @@ export async function listDescendants( positioned.forEach((position, index) => { const row = rows[index] if (row !== undefined) { - entries.push({ ...row, parentId: position.parentId, depth: position.depth }) + entries.push({ + ...row, + ...row.kind === 'child' && (externalByParent.get(row.id) as ExternalSubagentRecord[]).length > 0 ? { hasChildren: true } : {}, + parentId: position.parentId, + depth: position.depth, + }) } }) - return entries + const depths = new Map([[rootSessionId, 0], ...tree.map(position => [position.record.header.id, position.depth] as const)]) + for (const [parentId, records] of externalByParent) { + for (const record of records) { + entries.push({ ...externalRow(record), parentId, depth: (depths.get(parentId) as number) + 1 }) + } + } + return orderDescendants(entries, tree, externalByParent, rootSessionId) } /** Resolve listing services once and build one live-preferred session corpus. */ @@ -190,7 +221,7 @@ async function prepareListing( subagentParents.add(record.header.parentSession) } } - return { projections, query, cache, corpus, subagentParents } + return { projections, query, cache, corpus, subagentParents, observations: new Map() } } /** Resolve projection-backed rows for aligned candidates with bounded cold reads. */ @@ -199,7 +230,7 @@ async function resolveCandidateRows( listing: ListingRuntime, signal: AbortSignal | undefined, ): Promise<(SubagentListEntry | undefined)[]> { - const { projections, query, cache, subagentParents } = listing + const { projections, subagentParents } = listing const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length }) const coldReads: { index: number; header: SessionHeader }[] = [] candidates.forEach((candidate, index) => { @@ -208,11 +239,14 @@ async function resolveCandidateRows( coldReads.push({ index, header: candidate.header }) return } - // Read only the identity unit. A live child without an identity yet is the + // A live child without an identity yet is the // creation window before the establishing provider appends its descriptor. let identity: SubagentIdentityProjection | null | undefined + let externalChildren = false try { - identity = projections.snapshot(candidate.live, ['subagent']).values.subagent + const snapshot = projections.snapshot(candidate.live, ['subagent', 'subagentExternal']) + identity = snapshot.values.subagent + externalChildren = (snapshot.values.subagentExternal?.length ?? 0) > 0 } catch { // A rejecting identity fold is deterministic data damage in this child; // contain it as one diagnostic instead of failing the whole listing. @@ -223,7 +257,7 @@ async function resolveCandidateRows( // only mean the key was dropped at a JSON boundary. Both are no value. if (identity === undefined || identity === null || !candidate.live.isOwnSeq(identity.seq)) return - rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId)) + rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId) || externalChildren) }) // Cold candidates came from the query corpus and are resolved concurrently. @@ -234,7 +268,7 @@ async function resolveCandidateRows( async () => { for (let job = queue.shift(); job !== undefined; job = queue.shift()) { rows[job.index] = await resolveColdIdentity( - query, cache, job.header, + listing, job.header, subagentParents.has(job.header.id), signal, ) } @@ -272,7 +306,7 @@ function descendantCandidates( const id = position.record.header.id if (visited.has(id)) continue visited.add(id) - if (position.record.header.origin === 'subagent') positioned.push(position) + positioned.push(position) const descendants = children.get(id) ?? [] for (const record of [...descendants].reverse()) { stack.push({ record, parentId: id, depth: position.depth + 1 }) @@ -295,21 +329,24 @@ function compareCorpusRecords(a: CorpusRecord, b: CorpusRecord): number { * throw — are final, so they report `corrupt`. */ async function resolveColdIdentity( - query: SessionQueryEngine, - cache: SessionProjectionCache | undefined, + listing: ListingRuntime, header: SessionHeader, hasChildren: boolean, signal: AbortSignal | undefined, ): Promise { const childId = header.id + const { cache } = listing // A header deliberately exposes only whether a fork cut exists, not its // integer. An unseeded lifecycle has the exact cut 0 and may use the cache; // a seeded lifecycle must read the body before an identity seq can be // classified as inherited or owned. if (cache !== undefined && !header.isSeeded) { let cached: SubagentIdentityProjection | null | undefined + let externalChildren = false try { - cached = cache.cachedSnapshot(header, SessionLogOffset(0), ['subagent'])?.values.subagent + const snapshot = cache.cachedSnapshot(header, SessionLogOffset(0), ['subagent', 'subagentExternal']) + cached = snapshot?.values.subagent + externalChildren = (snapshot?.values.subagentExternal?.length ?? 0) > 0 } catch { // Unlike the preparation fold below, a throwing cache read renders no // verdict: the cache is derived data, so its damage (a poisoned stored @@ -321,15 +358,13 @@ async function resolveColdIdentity( // sentinel, whose verdict belongs to the authoritative re-fold, not to a // derived row. if (cached !== undefined && cached !== null) { - return childRow(childId, cached, 'inactive', hasChildren) + return childRow(childId, cached, 'inactive', hasChildren || externalChildren) } } assertListingNotCancelled(signal) - let observation: SessionObservation + let observation: ListingObservation try { - observation = await query.observeSession(childId, { - ...(signal === undefined ? {} : { signal }), - }) + observation = await observeCandidate(listing, childId, signal) } catch (error: unknown) { // Per-child isolation: durable corruption is stable; absence and backend // failures remain retryable. Either way, the listing itself still succeeds. @@ -343,20 +378,125 @@ async function resolveColdIdentity( : 'unavailable', } } - using ownedObservation = observation assertListingNotCancelled(signal) // A session id names a slot, not a lifecycle: a child deleted and // re-published under another owner between the enumeration and this read // must not leak into the old parent's listing. - if (!sameLifecycle(ownedObservation.header, header)) { + if (!sameLifecycle(observation.header, header)) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } - const identity = ownedObservation.projections?.values.subagent + const identity = observation.projections?.values.subagent if (identity === undefined || identity === null - || identity.seq < ownedObservation.inheritedEventCount) { + || identity.seq < observation.inheritedEventCount) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } - return childRow(childId, identity, 'inactive', hasChildren) + return childRow(childId, identity, 'inactive', hasChildren || (observation.projections?.values.subagentExternal?.length ?? 0) > 0) +} + +/** Share a cold Session observation between identity and external-child readers. */ +function observeCandidate( + listing: ListingRuntime, + id: SessionId, + signal: AbortSignal | undefined, +): Promise { + const existing = listing.observations.get(id) + if (existing !== undefined) return existing + const reading = (async (): Promise => { + using observation = await listing.query.observeSession(id, { + ...signal === undefined ? {} : { signal }, + }) + return { + header: observation.header, + inheritedEventCount: observation.inheritedEventCount, + ...observation.projections === undefined ? {} : { projections: observation.projections }, + } + })() + listing.observations.set(id, reading) + return reading +} + +/** Read external execution identities from their actual parent, never a synthetic child log. */ +async function resolveExternalRecords( + parentId: SessionId, + listing: ListingRuntime, + signal: AbortSignal | undefined, + required = false, +): Promise { + const parent = listing.corpus.get(parentId) + if (parent === undefined) return [] + assertListingNotCancelled(signal) + try { + if (parent.live !== undefined) { + return listing.projections.snapshot(parent.live, ['subagentExternal']).values.subagentExternal ?? [] + } + if (listing.cache !== undefined && !parent.header.isSeeded) { + let cached: ReturnType + try { + cached = listing.cache.cachedSnapshot(parent.header, SessionLogOffset(0), ['subagentExternal']) + } catch (_error: unknown) { + // A damaged derived cache cannot replace the authoritative parent read. + cached = undefined + } + if (cached?.values.subagentExternal !== undefined) return cached.values.subagentExternal + } + const observation = await observeCandidate(listing, parentId, signal) + assertListingNotCancelled(signal) + if (!sameLifecycle(observation.header, parent.header)) { + throw new SubagentError('external subagent parent changed during listing', 'SUBAGENT_CONTROL_PARENT_CHANGED') + } + return observation.projections?.values.subagentExternal ?? [] + } catch (error: unknown) { + assertListingNotCancelled(signal) + if (required) throw error + // An unreadable nested child gets its own identity diagnostic. Its external + // descendants cannot be discovered until that parent becomes readable. + return [] + } +} + +/** Project one external execution without advertising a nonexistent child Session. */ +function externalRow(record: ExternalSubagentRecord): SubagentListEntry { + return { + kind: 'child', + id: record.childId, + mode: 'one-shot', + label: record.label, + external: true, + activity: 'inactive', + hasChildren: false, + } +} + +/** Merge real Session edges and parent-owned external leaves in stable pre-order. */ +function orderDescendants( + entries: SubagentDescendantListEntry[], + tree: readonly PositionedCandidate[], + externalByParent: ReadonlyMap, + rootId: SessionId, +): SubagentDescendantListEntry[] { + const children = new Map() + const append = (parentId: SessionId, id: SessionId, createdAt: number): void => { + const siblings = children.get(parentId) + if (siblings === undefined) children.set(parentId, [{ id, createdAt }]) + else siblings.push({ id, createdAt }) + } + for (const position of tree) append(position.parentId, position.record.header.id, position.record.header.createdAt) + for (const [parentId, records] of externalByParent) { + for (const record of records) append(parentId, record.childId, record.createdAt) + } + for (const siblings of children.values()) { + siblings.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)) + } + const byId = new Map(entries.map(entry => [entry.id, entry])) + const ordered: SubagentDescendantListEntry[] = [] + const stack = (children.get(rootId) ?? []).map(child => child.id).reverse() + while (stack.length > 0) { + const id = stack.pop() as SessionId + const entry = byId.get(id) + if (entry !== undefined) ordered.push(entry) + for (const child of [...children.get(id) ?? []].reverse()) stack.push(child.id) + } + return ordered } /** Materialize one served identity as its child row. */ diff --git a/packages/subagent/subagent/src/run-settlement.ts b/packages/subagent/subagent/src/run-settlement.ts deleted file mode 100644 index 02f61a7baf..0000000000 --- a/packages/subagent/subagent/src/run-settlement.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Settlement of one ONE-SHOT subagent run into a background-Task outcome. Only - * the one-shot background path uses Jobs; continuable children have no Task, - * no per-message result, and no Task cancellation. - * - * @module @deepseek-ai/dsh-subagent/run-settlement - */ - -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { JobOutcome } from '@deepseek-ai/dsh-jobs' -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('') -} - -/** Render a failed stop reason with optional provider-authored detail. */ -function failureDetail(result: SubagentResult): string { - const stopReason = result.stopReason - return result.diagnostic === undefined - ? stopReason - : `${stopReason}; diagnostic: ${result.diagnostic}` -} - -/** - * Map a child result to the task outcome: completed carries final text, local - * cancellation (`aborted` without a diagnostic) is killed, and provider- - * diagnosed remote aborts plus every other reason are failed without partial - * output. - * @param result - child terminal result. - * @returns outcome for the `ctx.jobs` registration. - */ -function runOutcome(result: SubagentResult): JobOutcome { - switch (result.stopReason) { - case 'completed': - return { status: 'completed', output: finalText(result.output) } - case 'aborted': - return result.diagnostic === undefined - ? { status: 'killed' } - : { status: 'failed', detail: failureDetail(result) } - case 'error': - case 'max-tokens': - case 'refusal': - return { status: 'failed', detail: failureDetail(result) } - // Merge-extensible reasons remain failures with provider-authored detail. - default: - return { status: 'failed', detail: failureDetail(result) } - } -} - -/** - * 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: JobOutcome - 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 -} diff --git a/packages/subagent/subagent-in-process-driver/src/structured.ts b/packages/subagent/subagent/src/structured.ts similarity index 94% rename from packages/subagent/subagent-in-process-driver/src/structured.ts rename to packages/subagent/subagent/src/structured.ts index 2d266f9120..f8276aaee7 100644 --- a/packages/subagent/subagent-in-process-driver/src/structured.ts +++ b/packages/subagent/subagent/src/structured.ts @@ -7,7 +7,7 @@ * Capture commits only after the authoritative `tools/result` succeeds; PTC mode capture also * waits for the enclosing `run_code` result. The terminal result marker and monotonic tool * guard prevent later calls from reopening a completed structured run. - * @module @deepseek-ai/dsh-subagent-in-process-driver/structured + * @module @deepseek-ai/dsh-subagent/structured */ import type { Context } from '@deepseek-ai/cordis' @@ -44,9 +44,14 @@ export interface StructuredAttachment { * @param childCtx - the child agent's scope context (`setup`'s argument). * @param schema - the trusted, already-asserted schema subset to enforce (see * `assertObjectJsonSchema` in dsh-tools). + * @param canComplete - whether owned child work has finished. * @returns the attachment handle (read `captured()` after the child settles). */ -export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSchema): StructuredAttachment { +export function attachStructuredRuntime( + childCtx: Context, + schema: ObjectJsonSchema, + canComplete: () => boolean, +): StructuredAttachment { /** * Validated values staged by the capture tool body, awaiting THEIR OWN * authoritative `tools/result` notification. The execution object's identity @@ -87,6 +92,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch // ToolArgsError → isError result with INVALID_ARGS: the model retries // within the same turn, exactly like a schema-validated defineTool call. if (violations.length > 0) throw new ToolArgsError(violations) + if (!canComplete()) throw new Error('Wait for all delegated child tasks to finish before submitting structured output.') // Two-phase commit, keyed by THIS execution: later transformable // waterfalls may still turn the success into an error. ToolRuntime has // already frozen model-bound arguments at the actual input boundary. diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 1b9ce56c3f..8d7e630c9f 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -28,33 +28,32 @@ export function SubagentRunId(id: string): SubagentRunId { return id as SubagentRunId } -/** 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. */ +/** One managed child execution and its result delivery policy. */ +export interface SubagentActivationSpec { + /** Registered backend to use. */ readonly provider: string - /** The initial delegation's short `description`, persisted as the child's creation label. */ + /** Short task label retained in the parent catalog. */ readonly label: string - /** - * Optional caller-reserved child identity. Omission preserves the manager's - * UUID allocation; supplying one lets a durable parent record provisioning - * before child materialization without a second identity handshake. - */ + /** Optional reserved identity for a local child; external backends allocate their own ids. */ readonly childId?: SessionId - /** - * The delegation request. The manager reserves the stable child id, resolves - * the durable descriptor, and composes the child itself. - */ - readonly request: Omit - /** Caller cancellation, owning the operation only until inbox acceptance. */ + /** Task, parent, and backend-supported execution options. */ + readonly request: Omit + /** Cancellation before publication; callers own later cancellation through dispose. */ readonly signal: AbortSignal + /** Parent-model notification or a program-owned result. */ + readonly delivery: 'parent' | 'caller' } -/** Identities returned once a continuable child accepted its initial prompt. */ -export interface ContinuableStart { - /** The durable child session id, stable across activations. */ +/** A managed execution; disposal addresses this exact activation, never a later resume. */ +export interface SubagentActivation { + /** Stable identity of the child. */ readonly childId: SessionId - /** The accepted initial prompt's inbox message id. */ - readonly messageId: MessageId + /** Accepted inbox message, when the backend has a local inbox. */ + readonly messageId?: MessageId + /** Execution result, independently of eventual resource release. */ + readonly result: Promise + /** Stop and release this activation and its owned descendants. */ + dispose(): Promise } /** @@ -89,7 +88,7 @@ export interface SubagentRunInfo { readonly provider: string /** The child agent's id. */ readonly id: SessionId - /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ + /** Whether this activation owns a local Agent, fixed at publication. */ readonly local: boolean } @@ -104,7 +103,7 @@ export interface SubagentRunEndInfo { readonly provider: string /** The child agent's id. */ readonly id: SessionId - /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ + /** Whether this activation owns a local Agent, fixed at publication. */ readonly local: boolean /** The terminal stop reason. */ readonly stopReason: SubagentResult['stopReason'] @@ -117,15 +116,10 @@ export interface SubagentRunEndInfo { } /** - * 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 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. + * Start-time options supported by a registered backend. The manager checks + * every requested option before preparing a local child or starting an external + * execution. depthLimit controls maxDepth; the remaining flag names match + * SubagentStartRequest fields. */ export interface SubagentCapabilities { readonly agentOptions: boolean @@ -136,11 +130,9 @@ export interface SubagentCapabilities { } /** - * 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 - * and resolves the durable descriptor before dispatching to - * {@link SubagentProvider.start}. + * Task and optional capabilities supplied to a backend. startActivation carries + * these fields in request while owning its label, cancellation, and delivery + * policy separately. Providers receive a resolved descriptor before start. */ export interface SubagentStartRequest { /** Optional short display label persisted with a session-backed child. */ @@ -201,7 +193,7 @@ export interface SubagentStartRequest { } /** - * Provider-facing one-shot request after {@link SubagentRuntime.start} resolves + * Provider-facing execution request after {@link SubagentRuntime.startActivation} resolves * the durable child descriptor. */ export interface ResolvedSubagentStartRequest extends SubagentStartRequest { @@ -297,13 +289,11 @@ export interface SubagentResult { } /** - * ONE-SHOT child handle returned after publication. Prompt submission, turn - * work, and infrastructure faults after that boundary belong to {@link result}. - * Consumers await that 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. + * Backend execution handle owned by the external activation driver. + * A result may become available before resource release; the manager always + * disposes the handle and awaits cleanup. Backend startup failures clean up + * partial resources before rejecting, while accepted execution failures settle + * through result. */ export interface SubagentRun { /** @@ -353,14 +343,14 @@ export interface SubagentProvider { */ readonly inheritsParentContext: boolean /** - * Optional static provider-owned provider/model route for one-shot Agent + * Optional static provider-owned provider/model route for child Agent * options. Consumers merge tool/model overrides over these values before * preflight; providers whose route derives from the parent omit it. The value * is detached immutable data and requires `agentOptions` support. */ readonly agentRouteDefaults?: Readonly<{ provider: string; model: string }> /** - * Establish a ONE-SHOT child and return its handle after publication. + * Establish one external execution and return its owned handle. * The service has already validated that every requested start-time * capability is supported and resolved `request.descriptor`, so a * session-backed implementation appends that descriptor inside the child's @@ -370,14 +360,13 @@ export interface SubagentProvider { * the returned run. Distinct starts may overlap; cancellation, failure, * result settlement, and disposal remain independent for each run. */ - start(request: ResolvedSubagentStartRequest): Promise + start?(request: ResolvedSubagentStartRequest): Promise /** * OPTIONAL (continuable-creation capability): contribute the detached * creation inputs that distinguish this provider's continuable children — * 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. + * presence selects local activation execution. Providers without it execute + * through start and do not accept subsequent messages. * * This is the provider's ONLY participation in a continuable child. The * continuation manager owns identity reservation, composition, Agent diff --git a/packages/subagent/subagent/tests/activation-driver.spec.ts b/packages/subagent/subagent/tests/activation-driver.spec.ts new file mode 100644 index 0000000000..212c320350 --- /dev/null +++ b/packages/subagent/subagent/tests/activation-driver.spec.ts @@ -0,0 +1,47 @@ +/** Driver readiness and terminal behavior before and after an external execution settles. */ + +import { describe, expect, it, vi } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { ExternalActivationDriver } from '../src/activation-driver.ts' +import type { SubagentResult } from '../src/types.ts' + +it('rejects follow-up input and cannot close or expose a result before the backend settles', async () => { + const result = Promise.withResolvers() + const dispose = vi.fn(() => Promise.resolve()) + const controller = new AbortController() + const driver = new ExternalActivationDriver({ + id: SessionId('pending-external'), localAgent: undefined, result: result.promise, dispose, + }, controller) + const close = vi.fn() + expect(driver.version).toBe(0) + expect(driver.closeWhenIdle(close)).toBe(false) + expect(close).not.toHaveBeenCalled() + expect(() => driver.capture()).toThrow('has not settled') + expect(() => { driver.deliver(createUserMessage({ content: [], source: { kind: 'user' } }), 'queue') }) + .toThrow('does not accept follow-up input') + const terminal: SubagentResult = { output: [{ type: 'text', text: 'ready' }], stopReason: 'completed' } + result.resolve(terminal) + await driver.whenIdle() + expect(driver.version).toBe(1) + expect(driver.capture()).toEqual(terminal) + expect(driver.closeWhenIdle(close)).toBe(true) + expect(close).toHaveBeenCalledTimes(1) + await driver.dispose() +}) + +describe('external driver infrastructure rejection', () => { + it('waits for settlement while preserving the failure for the activation owner', async () => { + const result = Promise.withResolvers() + const failure = new Error('transport unavailable') + const dispose = vi.fn(() => Promise.resolve()) + const driver = new ExternalActivationDriver({ + id: SessionId('failed-external'), localAgent: undefined, result: result.promise, dispose, + }, new AbortController()) + result.reject(failure) + await driver.whenIdle() + expect(driver.version).toBe(1) + expect(() => driver.capture()).toThrow(failure) + await driver.dispose() + }) +}) diff --git a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts b/packages/subagent/subagent/tests/activation-policy.spec.ts similarity index 88% rename from packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts rename to packages/subagent/subagent/tests/activation-policy.spec.ts index 81a34bc3c3..7329b3941f 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent/tests/activation-policy.spec.ts @@ -18,9 +18,9 @@ import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-p import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import ApprovalService from '@deepseek-ai/dsh-user-approval' -import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { startInProcessRun } from '../src/index.ts' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import { mountLocalActivations, startPreparedActivation } from './local-activation.ts' type Script = ConstructorParameters[0] @@ -41,6 +41,8 @@ async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agen const ctx = new Context() contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) + await mountLocalActivations(ctx) + await ctx.plugin(SubagentRuntime) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace }) await ctx.plugin(SandboxedFileSystem, { cwd: workspace }) await ctx.plugin(ToolFs) @@ -61,11 +63,7 @@ function spawnRequest(parent: Agent) { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal: new AbortController().signal, - descriptor: snapshotSubagentDescriptor({ - mode: 'one-shot', - provider: 'spawn', - label: 'child task', - }), + } } @@ -90,14 +88,14 @@ describe('in-process policy inheritance', () => { current: (session: Session) => session === parent.session ? preset : 'custom', } as never) - const run = await startInProcessRun(spawnRequest(parent), {}) + const run = await startPreparedActivation(spawnRequest(parent), {}) try { await run.result - const child = run.localAgent as Agent - expect(child.session.snapshotEvents().slice(0, 3)).toMatchObject([ - { type: 'sandbox/mode', seq: 0, data: { mode: 'danger-full-access', source: 'delegation' } }, - { type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } }, - { type: 'permission/preset', seq: 2, data: { preset } }, + const child = run.localAgent + expect(child.session.snapshotEvents().slice(1, 4)).toMatchObject([ + { type: 'sandbox/mode', seq: 1, data: { mode: 'danger-full-access', source: 'delegation' } }, + { type: 'approval/policy', seq: 2, data: { policy: 'never', source: 'delegation' } }, + { type: 'permission/preset', seq: 3, data: { preset } }, ]) } finally { await run.dispose() @@ -119,13 +117,13 @@ describe('in-process policy inheritance', () => { current: (session: Session) => session === parent.session ? currentPreset : 'custom', } as never) - const starting = startInProcessRun(spawnRequest(parent), { seed }) + const starting = startPreparedActivation(spawnRequest(parent), { seed }) currentPreset = seedPreset parent.session.append('permission/preset', { preset: seedPreset }) const run = await starting try { await run.result - const child = run.localAgent as Agent + const child = run.localAgent expect(child.session.snapshotEvents().filter(event => event.type === 'permission/preset')).toMatchObject([ { data: { preset: seedPreset } }, { data: { preset } }, @@ -148,17 +146,17 @@ describe('in-process policy inheritance', () => { textResponse('child done'), ) - const run = await startInProcessRun(spawnRequest(parent), {}) + const run = await startPreparedActivation(spawnRequest(parent), {}) try { const result = await run.result - const child = run.localAgent as Agent + const child = run.localAgent await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) expect(result.stopReason).toBe('completed') - expect(child.session.snapshotEvents().slice(0, 2)).toMatchObject([ - { type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } }, - { type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } }, + expect(child.session.snapshotEvents().slice(1, 3)).toMatchObject([ + { type: 'sandbox/mode', seq: 1, data: { mode: 'read-only', source: 'delegation' } }, + { type: 'approval/policy', seq: 2, data: { policy: 'never', source: 'delegation' } }, ]) expect(child.session.firstLiveSeq).toBe(0) expect(child.session.header.isSeeded).toBe(false) @@ -198,7 +196,7 @@ describe('in-process policy inheritance', () => { .join('\n') expect(systemText).not.toContain('Approval prompts are disabled') expect(systemText).not.toContain('You are a delegated subagent') - expect(parent.session.snapshotEvents()).toHaveLength(parentLogLength) + expect(parent.session.snapshotEvents().filter(event => event.type !== 'subagent/catalog')).toHaveLength(parentLogLength) } finally { await run.dispose() } @@ -216,10 +214,10 @@ describe('in-process policy inheritance', () => { textResponse('child done'), ) - const run = await startInProcessRun(spawnRequest(parent), { seed }) + const run = await startPreparedActivation(spawnRequest(parent), { seed }) try { await run.result - const child = run.localAgent as Agent + const child = run.localAgent expect(child.session.header.isSeeded).toBe(true) expect(child.session.inheritedEventCount).toBe(1) @@ -227,7 +225,7 @@ describe('in-process policy inheritance', () => { // seq 1 is the constructor's end-seed marker. expect(child.session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toMatchObject([ { seq: 0, data: { mode: 'workspace-write' } }, - { seq: 2, data: { mode: 'read-only', source: 'delegation' } }, + { seq: 3, data: { mode: 'read-only', source: 'delegation' } }, ]) await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') @@ -244,12 +242,12 @@ describe('in-process policy inheritance', () => { const { ctx, parent } = await setupWalled(script) setSandboxMode(parent.session, 'read-only') - const starting = startInProcessRun(spawnRequest(parent), {}) + const starting = startPreparedActivation(spawnRequest(parent), {}) setSandboxMode(parent.session, 'danger-full-access') const run = await starting try { await run.result - const child = run.localAgent as Agent + const child = run.localAgent expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access') expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') } finally { @@ -266,14 +264,14 @@ describe('in-process policy inheritance', () => { textResponse('child done'), ) - const run = await startInProcessRun(spawnRequest(parent), {}) + const run = await startPreparedActivation(spawnRequest(parent), {}) try { await run.result - const child = run.localAgent as Agent + const child = run.localAgent expect(await readFile(allowed, 'utf8')).toBe('fine') expect(child.session.snapshotEvents().some(event => event.type === 'sandbox/mode')).toBe(false) expect(child.session.snapshotEvents().filter(event => event.type === 'approval/policy')).toMatchObject([ - { seq: 0, data: { policy: 'never', source: 'delegation' } }, + { seq: 1, data: { policy: 'never', source: 'delegation' } }, ]) expect(child.session.firstLiveSeq).toBe(0) } finally { @@ -302,10 +300,10 @@ describe('in-process policy inheritance', () => { textResponse('child done'), ) - const run = await startInProcessRun(spawnRequest(parent), {}) + const run = await startPreparedActivation(spawnRequest(parent), {}) try { await run.result - const child = run.localAgent as Agent + const child = run.localAgent await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) expect(consulted).toBe(false) diff --git a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts b/packages/subagent/subagent/tests/activation-presets.spec.ts similarity index 88% rename from packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts rename to packages/subagent/subagent/tests/activation-presets.spec.ts index 106b9e3eaa..2a6942d952 100644 --- a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/activation-presets.spec.ts @@ -18,11 +18,11 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import AgentPresets from '@deepseek-ai/dsh-agent-presets' import { SessionId } from '@deepseek-ai/dsh-session' -import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { startInProcessRun } from '../src/index.ts' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import { mountLocalActivations, startPreparedActivation } from './local-activation.ts' -const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'activation-presets') const ROOTS = [{ path: join(FIXTURES, 'presets'), trust: 'system' as const }] const contexts: Context[] = [] @@ -39,6 +39,8 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; await ctx.plugin(Loader) ctx.loader.builtins.include = Include await mountAgentLoopTestDependencies(ctx) + await mountLocalActivations(ctx) + await ctx.plugin(SubagentRuntime) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false }) const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) @@ -51,18 +53,14 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; return { ctx, adapter, parent: handle.agent } } -/** The one-shot spawn request shape both in-process providers build. */ +/** Task and caller cancellation used to create a fresh local activation. */ function spawnRequest(parent: Agent) { return { label: 'child task', prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal: new AbortController().signal, - descriptor: snapshotSubagentDescriptor({ - mode: 'one-shot' as const, - provider: 'spawn', - label: 'child task', - }), + } } @@ -70,7 +68,7 @@ describe('a child agent composed in-process', () => { it('reaches the model with its parent\'s preset tools', async () => { const { ctx, adapter, parent } = await setupPresetHost() - const run = await startInProcessRun(spawnRequest(parent), {}) + const run = await startPreparedActivation(spawnRequest(parent), {}) await run.result const childRequest = adapter.requests.at(-1) @@ -82,7 +80,7 @@ describe('a child agent composed in-process', () => { it('carries its parent\'s prompt sections', async () => { const { parent } = await setupPresetHost() - const run = await startInProcessRun(spawnRequest(parent), {}) + const run = await startPreparedActivation(spawnRequest(parent), {}) await run.result expect(run.localAgent?.session.snapshotEvents().some(event => @@ -94,7 +92,7 @@ describe('a child agent composed in-process', () => { it('records the composition it ran under on the child header', async () => { const { parent } = await setupPresetHost() - const run = await startInProcessRun(spawnRequest(parent), {}) + const run = await startPreparedActivation(spawnRequest(parent), {}) await run.result // Without this the child's own history reads back under the deployment @@ -106,7 +104,7 @@ describe('a child agent composed in-process', () => { it('honours a tool filter over the preset tools it inherited', async () => { const { ctx, parent } = await setupPresetHost() - const run = await startInProcessRun( + const run = await startPreparedActivation( { ...spawnRequest(parent), toolFilter: { deny: ['preset_only'] } }, {}, ) @@ -125,7 +123,7 @@ describe('a child agent composed in-process', () => { // to the same id would pass either way. await ctx.agentPresets.recompose(parent.ctx, 'reviewing') - const run = await startInProcessRun(spawnRequest(parent), {}) + const run = await startPreparedActivation(spawnRequest(parent), {}) await run.result expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['reviewing_only']) diff --git a/packages/subagent/subagent/tests/child-agent.spec.ts b/packages/subagent/subagent/tests/child-agent.spec.ts index 92302cfca4..433abb09ea 100644 --- a/packages/subagent/subagent/tests/child-agent.spec.ts +++ b/packages/subagent/subagent/tests/child-agent.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import { resolveChildAgentOptions } from '../src/child-agent.ts' +import { resolveChildAgentOptions, resolveChildDepth } from '../src/child-agent.ts' function parentAgent(): Agent { const id = SessionId('parent') @@ -74,3 +74,17 @@ describe('child Agent options', () => { }) }) }) + +describe('delegation depth limits', () => { + it.each([-1, -0, 0.5, Number.NaN, Number.POSITIVE_INFINITY])('rejects an invalid runtime depth %s', (depth) => { + const parent = parentAgent() + parent.options.subagentDepth = depth + expect(() => resolveChildDepth(parent, undefined)).toThrow('agent subagentDepth must be a non-negative safe integer') + }) + + it('rejects a child depth beyond the exact integer range', () => { + const parent = parentAgent() + parent.options.subagentDepth = Number.MAX_SAFE_INTEGER + expect(() => resolveChildDepth(parent, undefined)).toThrow('subagent child depth exceeds the safe-integer range') + }) +}) diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index b4458c3d2e..b76939dbaf 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -96,7 +96,7 @@ describe('continuable policy inheritance', () => { const current = vi.fn((session: Session) => session === parent.session ? preset : 'custom') ctx.provide('permissionPresets', { current } as never) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) @@ -143,7 +143,7 @@ describe('continuable policy inheritance', () => { current: (session: Session) => session === parent.session ? currentPreset : 'custom', } as never) - const starting = ctx.subagents.startContinuable(startSpec(parent, 'fork')) + const starting = ctx.subagents.startActivation({ ...startSpec(parent, 'fork'), delivery: 'parent' }) currentPreset = seedPreset parent.session.append('permission/preset', { preset: seedPreset }) const started = await starting @@ -165,7 +165,7 @@ describe('continuable policy inheritance', () => { if (agent !== parent) child = agent }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) // The delegation events are appended in the creation window, so they are // already the child's effective policy at inbox acceptance. if (child === undefined) throw new Error('expected the continuable child to be created') @@ -197,7 +197,7 @@ describe('continuable policy inheritance', () => { const { ctx, parent } = await setup([textResponse('child done')]) setSandboxMode(parent.session, 'read-only') - const starting = ctx.subagents.startContinuable(startSpec(parent)) + const starting = ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) // A parent switch after the synchronous capture belongs to the parent's // future, not to this child. setSandboxMode(parent.session, 'danger-full-access') @@ -212,7 +212,7 @@ describe('continuable policy inheritance', () => { it('leaves an unswitched sandbox on the deployment default while still pinning approval', { timeout: 20_000 }, async () => { const { ctx, parent } = await setup([textResponse('child done')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) @@ -230,7 +230,7 @@ describe('continuable policy inheritance', () => { })) await parent.whenIdle() - const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) + const started = await ctx.subagents.startActivation({ ...startSpec(parent, 'fork'), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) @@ -249,7 +249,7 @@ describe('continuable policy inheritance', () => { if (agent !== parent) child = agent }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) if (child === undefined) throw new Error('expected the continuable child to be created') expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access') // Last event wins: the child's own runtime switch beats the seeded snapshot. @@ -264,7 +264,7 @@ describe('continuable policy inheritance', () => { it('cold-resumes on the persisted snapshot without re-capturing the parent', { timeout: 20_000 }, async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')]) setSandboxMode(parent.session, 'read-only') - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) // The parent widens AFTER the child was created; the resumed child keeps @@ -302,7 +302,7 @@ describe('continuable policy inheritance', () => { await parent.whenIdle() setSandboxMode(parent.session, 'read-only') - const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) + const started = await ctx.subagents.startActivation({ ...startSpec(parent, 'fork'), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) diff --git a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts index 0458b7696d..9e1c2a191d 100644 --- a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts +++ b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts @@ -55,7 +55,8 @@ it('continues the parent through default Messages after a reasoning-bearing cont settled.resolve(undefined) }) - const started = await ctx.subagents.startContinuable({ + const started = await ctx.subagents.startActivation({ + delivery: 'parent', provider: 'spawn', label: 'child task', request: { parent, prompt: [{ type: 'text', text: 'child task' }] }, @@ -77,8 +78,6 @@ it('continues the parent through default Messages after a reasoning-bearing cont const notice = parent.session.deriveMessages().find(message => message.source.kind === 'subagent-settled') expect(notice?.content).toEqual([ { type: 'text', text: `Background subagent ${started.childId} finished and will do no further work unless you send it more.` }, - { type: 'text', text: 'Its closing message:' }, - { type: 'text', text: 'child answer' }, ]) expect(requests[1]?.body).toMatchObject({ messages: [{ role: 'user', content: notice?.content }] }) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 7f37523cd7..618af5be34 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -300,16 +300,16 @@ describe('continuable activation capacity', () => { parkParent(ctx, parent) try { await ctx.plugin(MemorySettings) - const first = await ctx.subagents.startContinuable(startSpec(parent)) - await expect(ctx.subagents.startContinuable(startSpec(parent))).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) + const first = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) await ctx.settings.update('subagent', { maxActiveSubagents: 2 }) - const second = await ctx.subagents.startContinuable(startSpec(parent)) + const second = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await ctx.settings.update('subagent', { maxActiveSubagents: 1 }) expect(ctx.agents.get(first.childId)).toBeDefined() expect(ctx.agents.get(second.childId)).toBeDefined() - await expect(ctx.subagents.startContinuable(startSpec(parent))).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) await ctx.settings.update('subagent', { maxActiveSubagents: 3 }) - const third = await ctx.subagents.startContinuable(startSpec(parent)) + const third = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) release.resolve(undefined) await Promise.all([first, second, third].map(child => waitNoActivation(ctx, child.childId))) } finally { @@ -327,11 +327,11 @@ describe('continuable activation capacity', () => { const { ctx, parent } = await setupWith(adapter) parkParent(ctx, parent) try { - const started = await Promise.all(Array.from({ length: 8 }, () => ctx.subagents.startContinuable(startSpec(parent)))) - await expect(ctx.subagents.startContinuable(startSpec(parent))).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) + const started = await Promise.all(Array.from({ length: 8 }, () => ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) release.resolve(undefined) await Promise.all(started.map(child => waitNoActivation(ctx, child.childId))) - const replacement = await ctx.subagents.startContinuable(startSpec(parent)) + const replacement = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, replacement.childId) } finally { release.resolve(undefined) @@ -351,21 +351,21 @@ describe('continuable activation capacity', () => { parkParent(ctx, parent) parkParent(ctx, other) try { - const first = await ctx.subagents.startContinuable(startSpec(parent)) + const first = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = ctx.agents.get(first.childId)! await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - const nested = await ctx.subagents.startContinuable(startSpec(child, 'fork')) - const sibling = await ctx.subagents.startContinuable(startSpec(parent)) - await expect(ctx.subagents.startContinuable(startSpec(ctx.agents.get(nested.childId)!))) + const nested = await ctx.subagents.startActivation({ ...startSpec(child, 'fork'), delivery: 'parent' }) + const sibling = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) + await expect(ctx.subagents.startActivation({ ...startSpec(ctx.agents.get(nested.childId)!), delivery: 'parent' })) .rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) - await expect(ctx.subagents.startContinuable(startSpec(parent))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })) .rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) - const independent = await ctx.subagents.startContinuable(startSpec(other)) + const independent = await ctx.subagents.startActivation({ ...startSpec(other), delivery: 'parent' }) parkParent(ctx, child) releaseParent.resolve(undefined) await child.whenIdle() expect(continuationActivations(ctx).get(first.childId)).toBeDefined() - await expect(ctx.subagents.startContinuable(startSpec(parent))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })) .rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) release.resolve(undefined) await Promise.all([first, nested, sibling, independent].map(entry => waitNoActivation(ctx, entry.childId))) @@ -387,16 +387,16 @@ describe('continuable activation capacity', () => { await release.promise throw new Error('creation failed') }) - const starting = ctx.subagents.startContinuable(startSpec(parent)) + const starting = ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const rejected = expect(starting).rejects.toThrow('creation failed') try { await entered.promise - await expect(ctx.subagents.startContinuable(startSpec(parent))).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) expect(createSpy).toHaveBeenCalledTimes(1) release.resolve(undefined) await rejected createSpy.mockRestore() - const replacement = await ctx.subagents.startContinuable(startSpec(parent)) + const replacement = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, replacement.childId) } finally { release.resolve(undefined) @@ -406,28 +406,34 @@ describe('continuable activation capacity', () => { } }) - it('keeps one-shot runs outside continuable capacity', async () => { + it('counts caller-awaited and parent-delivered runs against the same capacity', async () => { const release = Promise.withResolvers() + const releaseCaller = Promise.withResolvers() const adapter = new GatedAdapter([ - { chunks: textResponse('continuable'), gate: release.promise }, - { chunks: textResponse('one-shot') }, + { chunks: textResponse('parent-delivered'), gate: release.promise }, + { chunks: textResponse('caller-awaited'), gate: releaseCaller.promise }, ]) const { ctx, parent } = await setupWith(adapter, { maxActiveSubagents: 1 }) parkParent(ctx, parent) try { - const child = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - const run = await ctx.subagents.start('spawn', { parent, prompt: message('one-shot'), signal: testSignal }) - try { - expect((await run.result).output).toEqual(message('one-shot')) - } finally { - await run.dispose() - } - await expect(ctx.subagents.startContinuable(startSpec(parent))).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'caller' })) + .rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) release.resolve(undefined) await waitNoActivation(ctx, child.childId) + + const caller = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'caller' }) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })) + .rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) + releaseCaller.resolve(undefined) + expect((await caller.result).output).toEqual(message('caller-awaited')) + await caller.dispose() + await waitNoActivation(ctx, caller.childId) } finally { release.resolve(undefined) + releaseCaller.resolve(undefined) await ctx.fiber.dispose() } }) @@ -443,9 +449,9 @@ describe('continuable activation capacity', () => { const { ctx, parent } = await setupWith(adapter, { maxActiveSubagents: 1 }) parkParent(ctx, parent) try { - const old = await ctx.subagents.startContinuable(startSpec(parent)) + const old = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, old.childId) - const busy = await ctx.subagents.startContinuable(startSpec(parent)) + const busy = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) await expect(queuePrompt(ctx, parent, old.childId, message('resume'))).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) await queuePrompt(ctx, parent, busy.childId, message('queue at capacity')) @@ -472,24 +478,24 @@ describe('continuable activation capacity', () => { const entered = Promise.withResolvers() const releaseDisposal = Promise.withResolvers() try { - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const activation = continuationActivations(ctx).get(started.childId)! - const dispose = activation.handle.dispose.bind(activation.handle) - activation.handle.dispose = async () => { + const dispose = activation.driver.dispose.bind(activation.driver) + activation.driver.dispose = async () => { entered.resolve(undefined) await releaseDisposal.promise await dispose() if (failDisposal) throw new Error('cleanup report failed') } - const drained = ctx.subagents.drainContinuableChildren(parent, [started.childId]) + const drained = ctx.subagents.drainChildren(parent, [started.childId]) const outcome = drained.catch((error: unknown) => error) releaseRun.resolve(undefined) await entered.promise - await expect(ctx.subagents.startContinuable(startSpec(parent))).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })).rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) releaseDisposal.resolve(undefined) expect(await outcome).toEqual(failDisposal ? expect.objectContaining({ code: 'ACTIVATION_TEARDOWN_FAILED' }) : undefined) - const replacement = await ctx.subagents.startContinuable(startSpec(parent)) + const replacement = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, replacement.childId) } finally { releaseRun.resolve(undefined) @@ -499,17 +505,17 @@ describe('continuable activation capacity', () => { }) }) -describe('SubagentRuntime.startContinuable', () => { +describe('SubagentRuntime.startActivation', () => { 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/inserted', ({ agent, message }) => { - // Acceptance is the boundary `startContinuable` resolves at, so observe + // Acceptance is the boundary `startActivation` resolves at, so observe // the log state exactly there rather than after later microtasks. enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.snapshotEvents(), 'child task') }) }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) expect(started.childId).toMatch(/[0-9a-f-]{36}/) // The returned id is exactly the accepted inbox message's id, and nothing @@ -530,14 +536,16 @@ describe('SubagentRuntime.startContinuable', () => { const { ctx, parent } = await setupWith(adapter) const reservedId = SessionId('00000000-0000-4000-8000-000000000123') - const started = await ctx.subagents.startContinuable({ + const started = await ctx.subagents.startActivation({ + delivery: 'parent', ...startSpec(parent), childId: reservedId, }) expect(started.childId).toBe(reservedId) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - await expect(ctx.subagents.startContinuable({ + await expect(ctx.subagents.startActivation({ + delivery: 'parent', ...startSpec(parent), childId: reservedId, })).rejects.toMatchObject({ code: 'DUPLICATE_CHILD' }) @@ -547,14 +555,15 @@ describe('SubagentRuntime.startContinuable', () => { const loaded = await loadStoredSession(ctx.sessionPersistence, reservedId) expect(loaded.meta.id).toBe(reservedId) - await expect(ctx.subagents.startContinuable({ + await expect(ctx.subagents.startActivation({ + delivery: 'parent', ...startSpec(parent), childId: reservedId, })).rejects.toMatchObject({ code: 'DUPLICATE_CHILD' }) expect(ctx.agents.get(reservedId)).toBeUndefined() }) - it('rejects without ids when the provider has no prepareContinuable capability', async () => { + it('rejects reserved child ids for an external backend before dispatch', async () => { const { ctx, parent } = await setup([]) const start = vi.fn(async () => { throw new Error('must not dispatch') }) ctx.subagents.registerProvider({ @@ -564,8 +573,8 @@ describe('SubagentRuntime.startContinuable', () => { start, }) - await expect(ctx.subagents.startContinuable(startSpec(parent, 'one-shot'))) - .rejects.toThrow(/does not support continuable children/) + await expect(ctx.subagents.startActivation({ ...startSpec(parent, 'one-shot'), delivery: 'parent', childId: SessionId('reserved-external') })) + .rejects.toThrow(/reserved child ids require a local backend/) expect(start).not.toHaveBeenCalled() // No child Agent and no session were created. expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) @@ -573,13 +582,13 @@ describe('SubagentRuntime.startContinuable', () => { 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))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })) .rejects.toThrow(/require session persistence/) }) it('publishes the reserved child id and appends the pre-turn descriptor', async () => { const { ctx, parent } = await setup([textResponse('answer')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) @@ -619,7 +628,8 @@ describe('SubagentRuntime.startContinuable', () => { if (agent !== parent) childEfforts.push(agent.options.reasoningEffort) }) - const started = await ctx.subagents.startContinuable({ + const started = await ctx.subagents.startActivation({ + delivery: 'parent', ...startSpec(parent), request: { prompt: message('selected reasoning'), @@ -645,7 +655,7 @@ describe('SubagentRuntime.startContinuable', () => { if (child !== parent) controller.abort('caller gave up') }) - await expect(ctx.subagents.startContinuable(startSpec(parent, 'spawn', controller.signal))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent, 'spawn', controller.signal), delivery: 'parent' })) .rejects.toThrow() // No Activation, no live child Agent, and no parent ownership remains. await vi.waitFor(() => { @@ -660,7 +670,7 @@ describe('SubagentRuntime.startContinuable', () => { // 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))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent, 'spawn', controller.signal), delivery: 'parent' })) .rejects.toThrow() // No resident child and no queued turn survive the abort. @@ -682,8 +692,8 @@ describe('SubagentRuntime.startContinuable', () => { if (type === 'subagent/catalog') { const activation = continuationActivations(ctx).get(childId) if (activation === undefined) throw new Error('expected live Activation') - const dispose = activation.handle.dispose.bind(activation.handle) - vi.spyOn(activation.handle, 'dispose').mockImplementation(async () => { + const dispose = activation.driver.dispose.bind(activation.driver) + vi.spyOn(activation.driver, 'dispose').mockImplementation(async () => { await dispose() throw cleanupFailure }) @@ -692,7 +702,8 @@ describe('SubagentRuntime.startContinuable', () => { return appendCatalog('subagent/catalog', data as SessionEventMap['subagent/catalog']) }) as typeof parent.session.append) - await expect(ctx.subagents.startContinuable({ + await expect(ctx.subagents.startActivation({ + delivery: 'parent', ...startSpec(parent), childId, })).rejects.toBe(catalogFailure) @@ -712,15 +723,16 @@ describe('SubagentRuntime.startContinuable', () => { ctx.on('subagent/start', () => { const activation = continuationActivations(ctx).get(childId) if (activation === undefined) throw new Error('expected live Activation') - const dispose = activation.handle.dispose.bind(activation.handle) - vi.spyOn(activation.handle, 'dispose').mockImplementation(async () => { + const dispose = activation.driver.dispose.bind(activation.driver) + vi.spyOn(activation.driver, 'dispose').mockImplementation(async () => { await dispose() throw cleanupFailure }) controller.abort(admissionFailure) }) - await expect(ctx.subagents.startContinuable({ + await expect(ctx.subagents.startActivation({ + delivery: 'parent', ...startSpec(parent, 'spawn', controller.signal), childId, })).rejects.toBe(admissionFailure) @@ -738,7 +750,7 @@ describe('SubagentRuntime.startContinuable', () => { if (eventName === 'subagent/start') throw new Error('start publication failed') }, { global: true }) - await expect(ctx.subagents.startContinuable(startSpec(parent))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })) .rejects.toThrow(/start publication failed/) await vi.waitFor(() => { @@ -750,7 +762,8 @@ describe('SubagentRuntime.startContinuable', () => { it('rejects a continuable child that would exceed the configured depth cap', async () => { const { ctx, parent } = await setup([]) - await expect(ctx.subagents.startContinuable({ + await expect(ctx.subagents.startActivation({ + delivery: 'parent', ...startSpec(parent), request: { prompt: message('deep'), parent, maxDepth: 0 }, })).rejects.toThrow(/exceeds maxDepth 0/) @@ -759,7 +772,8 @@ describe('SubagentRuntime.startContinuable', () => { it('rejects an invalid continuable depth cap before provider preparation', async () => { const { ctx, parent } = await setup([]) - await expect(ctx.subagents.startContinuable({ + await expect(ctx.subagents.startActivation({ + delivery: 'parent', ...startSpec(parent), request: { prompt: message('deep'), parent, maxDepth: Number.NaN }, })).rejects.toThrow(/non-negative safe integer/) @@ -771,7 +785,7 @@ describe('SubagentRuntime.startContinuable', () => { // 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 = await ctx.agentLoop.create(SessionId('routeless'), {}) - const started = await ctx.subagents.startContinuable(startSpec(routeless)) + const started = await ctx.subagents.startActivation({ ...startSpec(routeless), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) expect(found).toBeDefined() @@ -802,7 +816,8 @@ describe('SubagentRuntime.startContinuable', () => { execute: () => Promise.resolve({}), })) const routeless = await ctx.agentLoop.create(SessionId('routeless-filtered'), {}) - const started = await ctx.subagents.startContinuable({ + const started = await ctx.subagents.startActivation({ + delivery: 'parent', ...startSpec(routeless), request: { prompt: message('filtered work'), parent: routeless, toolFilter: { deny: ['noop'] } }, }) @@ -826,7 +841,7 @@ describe('SubagentRuntime.startContinuable', () => { it('cold-resumes without inventing a model route the descriptor never declared', async () => { const { ctx, root } = await setup([textResponse('first')]) const routeless = await ctx.agentLoop.create(SessionId('routeless-resume'), {}) - const started = await ctx.subagents.startContinuable(startSpec(routeless)) + const started = await ctx.subagents.startActivation({ ...startSpec(routeless), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) // End the first lifecycle so its write leases release before the fresh // context re-creates the parent identity and cold-resumes the child. @@ -866,7 +881,7 @@ describe('SubagentRuntime.startContinuable', () => { parent.followup(createUserMessage({ content: message('parent work'), source: { kind: 'user' } })) await parent.whenIdle() - const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) + const started = await ctx.subagents.startActivation({ ...startSpec(parent, 'fork'), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId) @@ -883,7 +898,8 @@ describe('SubagentRuntime.startContinuable', () => { 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({ + const started = await ctx.subagents.startActivation({ + delivery: 'parent', ...startSpec(parent), request: { prompt: message('scoped work'), @@ -915,7 +931,7 @@ describe('continuable image Queue prompts', () => { it('refuses an image follow-up when the child model declines image input, leaving no partial message', async () => { const { ctx, parent } = await setup([textResponse('child work')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const resolve = vi.spyOn(ctx.llm, 'resolveModelInfo') .mockResolvedValue({ inputModalities: ['text'] } as never) @@ -939,7 +955,7 @@ describe('continuable image Queue prompts', () => { { chunks: textResponse('image reply') }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) @@ -967,7 +983,7 @@ describe('continuable image Queue prompts', () => { const releaseFirst = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('child work'), gate: releaseFirst.promise }]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const capability = Promise.withResolvers<{ inputModalities: string[] }>() const readingCapability = Promise.withResolvers() @@ -994,7 +1010,7 @@ describe('continuable image Queue prompts', () => { it('rejects a materialized image follow-up whose capability read raced a drain', async () => { const { ctx, parent } = await setup([textResponse('child work')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const capability = Promise.withResolvers<{ inputModalities: string[] }>() const readingCapability = Promise.withResolvers() @@ -1023,7 +1039,7 @@ describe('continuable image Queue prompts', () => { it('defers to the text-only projection when the descriptor declares no model route', async () => { const { ctx } = await setup([]) const routeless = await ctx.agentLoop.create(SessionId('routeless-image'), {}) - const started = await ctx.subagents.startContinuable(startSpec(routeless)) + const started = await ctx.subagents.startActivation({ ...startSpec(routeless), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const resolve = vi.spyOn(ctx.llm, 'resolveModelInfo') @@ -1055,7 +1071,7 @@ describe('direct-child Queue residency routing', () => { { chunks: textResponse('third') }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId) expect(child?.status).toBe('running') @@ -1075,7 +1091,7 @@ describe('direct-child Queue residency routing', () => { 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)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const messageId = await queuePrompt(ctx, parent, started.childId, message('continue please')) @@ -1104,7 +1120,7 @@ describe('direct-child Queue residency routing', () => { 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')) + const started = await ctx.subagents.startActivation({ ...startSpec(parent, 'retired'), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) disposeProvider() expect(ctx.subagents.getProvider('retired')).toBeUndefined() @@ -1129,14 +1145,14 @@ describe('direct-child Queue residency routing', () => { { chunks: textResponse('woken') }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: '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)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests.length).toBeGreaterThanOrEqual(2) }) await vi.waitFor(() => { expect(child.status).toBe('idle') @@ -1167,16 +1183,16 @@ describe('direct-child Queue residency routing', () => { { chunks: textResponse('extra delivery') }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) expect(found).toBeDefined() return found! }) - // startContinuable held the child→grandchild ownership edge; a followup + // startActivation held the child→grandchild ownership edge; a followup // from the same managed parent to the same child re-holds it as a no-op, // and its failure-path release must not drop the established edge. - const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' }) const aborted = AbortSignal.abort(new Error('followup abandoned')) await expect(queuePrompt(ctx, child, grandchild.childId, message('while owned'), aborted)) .rejects.toThrow('followup abandoned') @@ -1192,7 +1208,7 @@ describe('direct-child Queue residency routing', () => { 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)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const stranger = await ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) @@ -1201,24 +1217,21 @@ describe('direct-child Queue residency routing', () => { }) 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 has durable identity but no supported continuation state. - const run = await ctx.subagents.start('spawn', { - label: 'one-shot work', - prompt: message('one-shot work'), - parent, - signal: testSignal, + const { ctx, parent } = await setup([]) + const childId = SessionId('child-without-descriptor') + const child = await ctx.agents.create({ + sessionId: childId, + meta: { origin: 'subagent', parentSession: parent.id }, + agentOptions: { provider: 'mock', model: 'mock' }, }) - await run.result - await ctx.sessions.flush(run.localAgent!.session) - const oneShotId = run.id - await run.dispose() + await ctx.sessions.flush(child.agent.session) + await child.dispose() - const rejection: unknown = await queuePrompt(ctx, parent, oneShotId, message('continue')) + const rejection: unknown = await queuePrompt(ctx, parent, childId, message('continue')) .catch((error: unknown) => error) expect(rejection).toMatchObject({ code: 'NOT_RESUMABLE', - message: `subagent "${oneShotId}" has no supported continuation state and cannot be resumed; choose a different target`, + message: `subagent "${childId}" has no supported continuation state and cannot be resumed; choose a different target`, }) expect(String(rejection)).not.toContain('send_message') }) @@ -1231,7 +1244,7 @@ describe('direct-child Queue residency routing', () => { it('propagates cancellation while inspecting a cold child', async () => { const { ctx, parent } = await setup([textResponse('first')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const inspectStarted = Promise.withResolvers() const inspect = vi.spyOn(ctx.sessionPersistence, 'open').mockImplementation((_id, _access, options) => { @@ -1262,7 +1275,7 @@ describe('direct-child Queue residency routing', () => { it('preserves a SubagentError raised while cold-materializing a child', async () => { const { ctx, parent } = await setup([textResponse('first')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const failure = new SubagentError('materialization denied', 'UNAUTHORIZED') ctx.agents.resume = () => Promise.reject(failure) @@ -1273,7 +1286,7 @@ describe('direct-child Queue residency routing', () => { 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 started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) expect(found).toBeDefined() @@ -1301,7 +1314,7 @@ describe('continuable human steering delivery', () => { ]) const { ctx, parent } = await setupWith(adapter) parkParent(ctx, parent) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! @@ -1319,7 +1332,7 @@ describe('continuable human steering delivery', () => { it('cold-resumes steering into nextStep instead of inventing another queue', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('steered')]) parkParent(ctx, parent) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const receipt = await humanPrompt(ctx, parent, started.childId, 'cold steer', 'steer') @@ -1340,7 +1353,7 @@ describe('continuable child ownership', () => { { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, ]) const { ctx, parent } = await setupWith(adapter, { schedule: true }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) expect(found).toBeDefined() @@ -1350,7 +1363,7 @@ describe('continuable child ownership', () => { expect(ctx.agents.isOwnedBy(child.id, parent)).toBe(true) expect(ctx.tools.get('schedule_create', parent)).toBeDefined() expect(ctx.tools.get('schedule_create', child)).toBeUndefined() - const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' }) await vi.waitFor(() => { expect(child.status).toBe('idle') @@ -1367,7 +1380,7 @@ describe('continuable child ownership', () => { 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)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) // The top-level parent remains independently registered after its child settles. expect(ctx.agents.get(parent.id)).toBe(parent) @@ -1394,7 +1407,7 @@ describe('continuable durability and teardown', () => { await releaseFlush.promise }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = ctx.agents.get(started.childId)! const cancelSpy = vi.spyOn(child, 'cancel') releaseFirstTurn.resolve(undefined) @@ -1433,7 +1446,7 @@ describe('continuable durability and teardown', () => { await releaseFlush.promise }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = ctx.agents.get(started.childId)! releaseFirstTurn.resolve(undefined) await flushing.promise @@ -1468,11 +1481,11 @@ describe('continuable durability and teardown', () => { await releaseFlush.promise }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = ctx.agents.get(started.childId)! releaseFirstTurn.resolve(undefined) await flushing.promise - const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) releaseFlush.resolve(undefined) @@ -1501,7 +1514,7 @@ describe('continuable durability and teardown', () => { await releaseFlush.promise }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) releaseFirstTurn.resolve(undefined) await flushing.promise const drained = drainManager(ctx) @@ -1527,7 +1540,7 @@ describe('continuable durability and teardown', () => { await releaseFlush.promise }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = ctx.agents.get(started.childId)! const cancelSpy = vi.spyOn(child, 'cancel') releaseFirstTurn.resolve(undefined) @@ -1565,7 +1578,7 @@ describe('continuable durability and teardown', () => { const warnings: string[] = [] ctx.logger.warn = (message: string) => { warnings.push(message) } - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) // Tear down the whole backend under the child's open write path: its // teardown closes the handle (draining what was buffered) and detaches @@ -1592,7 +1605,7 @@ describe('continuable durability and teardown', () => { if (session.header.parentSession !== undefined) peerFlushed = true }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) expect(peerFlushed).toBe(true) expect(warnings.some(warning => warning.includes('best-effort final session flush failed'))).toBe(true) @@ -1606,11 +1619,11 @@ describe('continuable durability and teardown', () => { const warnings: string[] = [] ctx.logger.warn = (message: string) => { warnings.push(message) } - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const activation = continuationActivations(ctx).get(started.childId)! - const realDispose = activation.handle.dispose.bind(activation.handle) - activation.handle.dispose = async () => { + const realDispose = activation.driver.dispose.bind(activation.driver) + activation.driver.dispose = async () => { await realDispose() throw new Error('normal settlement cleanup failed') } @@ -1630,13 +1643,13 @@ describe('continuable durability and teardown', () => { { chunks: textResponse('grandchild'), gate: hold.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: '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)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' }) await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() }) const disposals: SessionId[] = [] @@ -1670,20 +1683,20 @@ describe('continuable durability and teardown', () => { SessionId('sibling-parent'), { provider: 'mock', model: 'mock' }, ) - const target = await ctx.subagents.startContinuable(startSpec(parent)) - const sibling = await ctx.subagents.startContinuable(startSpec(siblingParent)) + const target = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) + const sibling = await ctx.subagents.startActivation({ ...startSpec(siblingParent), delivery: 'parent' }) 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)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(targetChild), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) }) const cancellations: SessionId[] = [] observeCancel(targetChild, () => { cancellations.push(targetChild.id) }) const grandchildAgent = ctx.agents.get(grandchild.childId)! observeCancel(grandchildAgent, () => { cancellations.push(grandchildAgent.id) }) - const drained = ctx.subagents.drainContinuableDescendants([parent]) - const convergedDrain = ctx.subagents.drainContinuableDescendants([parent]) + const drained = ctx.subagents.drainDescendants([parent]) + const convergedDrain = ctx.subagents.drainDescendants([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. @@ -1693,7 +1706,7 @@ describe('continuable durability and teardown', () => { expect(ctx.agents.get(sibling.childId)).toBe(siblingChild) await expect(queuePrompt(ctx, siblingParent, sibling.childId, message('still live'))) .resolves.toBeTypeOf('string') - await expect(ctx.subagents.startContinuable(startSpec(parent))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })) .rejects.toMatchObject({ code: 'DRAINING' }) await expect(queuePrompt(ctx, parent, target.childId, message('too late'))) .rejects.toMatchObject({ code: 'DRAINING' }) @@ -1706,7 +1719,7 @@ describe('continuable durability and teardown', () => { 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))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })) .rejects.toMatchObject({ code: 'DRAINING' }) releaseSibling.resolve(undefined) @@ -1721,16 +1734,16 @@ describe('continuable durability and teardown', () => { { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! - const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) const cancellations: SessionId[] = [] const grandchildAgent = ctx.agents.get(grandchild.childId)! observeCancel(grandchildAgent, () => { cancellations.push(grandchildAgent.id) }) - const drained = ctx.subagents.drainContinuableDescendants([child]) + const drained = ctx.subagents.drainDescendants([child]) expect(cancellations).toEqual([grandchild.childId]) expect(ctx.agents.get(started.childId)).toBe(child) @@ -1738,7 +1751,7 @@ describe('continuable durability and teardown', () => { await drained expect(ctx.agents.get(grandchild.childId)).toBeUndefined() expect(ctx.agents.get(started.childId)).toBe(child) - await expect(ctx.subagents.startContinuable(startSpec(child))) + await expect(ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' })) .rejects.toMatchObject({ code: 'DRAINING' }) releaseChild.resolve(undefined) @@ -1755,17 +1768,17 @@ describe('continuable durability and teardown', () => { { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const target = await ctx.subagents.startContinuable(startSpec(parent)) - const sibling = await ctx.subagents.startContinuable(startSpec(parent)) + const target = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) + const sibling = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) const targetAgent = ctx.agents.get(target.childId)! const siblingAgent = ctx.agents.get(sibling.childId)! - const grandchild = await ctx.subagents.startContinuable(startSpec(targetAgent)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(targetAgent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) }) const cancel = vi.spyOn(targetAgent, 'cancel') // A repeated id folds to one teardown and an absent target is an accepted no-op. - const drained = ctx.subagents.drainContinuableChildren( + const drained = ctx.subagents.drainChildren( parent, [target.childId, target.childId, SessionId('never-materialized')], ) @@ -1785,7 +1798,7 @@ describe('continuable durability and teardown', () => { it('ignores a selected direct child that is not resident', async () => { const { ctx, parent } = await setup([]) - await expect(ctx.subagents.drainContinuableChildren(parent, [SessionId('settled-child')])) + await expect(ctx.subagents.drainChildren(parent, [SessionId('settled-child')])) .resolves.toBeUndefined() }) @@ -1793,16 +1806,16 @@ describe('continuable durability and teardown', () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('target'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) - const target = await ctx.subagents.startContinuable(startSpec(parent)) + const target = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const activation = continuationActivations(ctx).get(target.childId)! - const realDispose = activation.handle.dispose.bind(activation.handle) - activation.handle.dispose = async () => { + const realDispose = activation.driver.dispose.bind(activation.driver) + activation.driver.dispose = async () => { await realDispose() throw new Error('selected cleanup failed') } - const drained = ctx.subagents.drainContinuableChildren(parent, [target.childId]) + const drained = ctx.subagents.drainChildren(parent, [target.childId]) hold.resolve(undefined) await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) @@ -1814,10 +1827,10 @@ describe('continuable durability and teardown', () => { const adapter = new GatedAdapter([{ chunks: textResponse('target'), gate: release.promise }]) const { ctx, parent } = await setupWith(adapter) const other = await ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' }) - const target = await ctx.subagents.startContinuable(startSpec(parent)) + const target = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - await expect(ctx.subagents.drainContinuableChildren(other, [target.childId])) + await expect(ctx.subagents.drainChildren(other, [target.childId])) .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) expect(ctx.agents.get(target.childId)).toBeDefined() @@ -1829,46 +1842,51 @@ describe('continuable durability and teardown', () => { const { ctx, parent } = await setup([]) const stale = { ...parent, id: parent.id } as unknown as Agent - await expect(ctx.subagents.drainContinuableChildren(stale, [])) + await expect(ctx.subagents.drainChildren(stale, [])) .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) }) - it('finds scoped descendants after an intermediate one-shot Agent leaves the registry', async () => { + it('finds scoped descendants after an intermediate host 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('host task'), gate: releaseIntermediate.promise }, { chunks: textResponse('continuable descendant'), gate: releaseDescendant.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const run = await ctx.subagents.start('spawn', { - label: 'one-shot task', - 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) }) + try { + const handle = await ctx.agents.create({ + sessionId: SessionId('intermediate-host-agent'), + meta: { origin: 'subagent', parentSession: parent.id }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const intermediate = handle.agent + intermediate.followup(createUserMessage({ content: message('host task'), source: { kind: 'user' } })) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const descendant = await ctx.subagents.startActivation({ ...startSpec(intermediate), delivery: 'parent' }) + 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[] = [] - const descendantAgent = ctx.agents.get(descendant.childId)! - observeCancel(descendantAgent, () => { cancellations.push(descendantAgent.id) }) + const intermediateId = intermediate.id + const disposingIntermediate = handle.dispose() + releaseIntermediate.resolve(undefined) + await disposingIntermediate + expect(ctx.agents.get(intermediateId)).toBeUndefined() + expect(ctx.agents.get(descendant.childId)).toBeDefined() + const cancellations: SessionId[] = [] + const descendantAgent = ctx.agents.get(descendant.childId)! + observeCancel(descendantAgent, () => { cancellations.push(descendantAgent.id) }) - const drained = ctx.subagents.drainContinuableDescendants([parent]) + const drained = ctx.subagents.drainDescendants([parent]) - expect(cancellations).toEqual([descendant.childId]) - releaseDescendant.resolve(undefined) - await drained - expect(ctx.agents.get(descendant.childId)).toBeUndefined() + expect(cancellations).toEqual([descendant.childId]) + releaseDescendant.resolve(undefined) + await drained + expect(ctx.agents.get(descendant.childId)).toBeUndefined() + } finally { + releaseIntermediate.resolve(undefined) + releaseDescendant.resolve(undefined) + await ctx.fiber.dispose() + } }) it('awaits and rolls back an admitted materialization below a scoped root', async () => { @@ -1885,10 +1903,10 @@ describe('continuable durability and teardown', () => { }) try { - const starting = ctx.subagents.startContinuable(startSpec(parent)) + const starting = ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const childId = await published.promise let drainResolved = false - const drained = ctx.subagents.drainContinuableDescendants([parent]).then(() => { + const drained = ctx.subagents.drainDescendants([parent]).then(() => { drainResolved = true }) await Promise.resolve() @@ -1907,8 +1925,8 @@ describe('continuable durability and teardown', () => { 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 ctx.subagents.drainDescendants([stale]) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) }) @@ -1919,16 +1937,16 @@ describe('continuable durability and teardown', () => { { chunks: textResponse('target child'), gate: hold.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const activation = continuationActivations(ctx).get(started.childId)! - const realDispose = activation.handle.dispose.bind(activation.handle) - activation.handle.dispose = async () => { + const realDispose = activation.driver.dispose.bind(activation.driver) + activation.driver.dispose = async () => { await realDispose() throw new Error('scoped child reap failed') } - const drained = ctx.subagents.drainContinuableDescendants([parent]) + const drained = ctx.subagents.drainDescendants([parent]) hold.resolve(undefined) await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) @@ -1937,12 +1955,12 @@ describe('continuable durability and teardown', () => { 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)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) await drainManager(ctx) - await expect(ctx.subagents.startContinuable(startSpec(parent))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })) .rejects.toMatchObject({ code: 'DRAINING' }) await expect(queuePrompt(ctx, parent, started.childId, message('too late'))) .rejects.toMatchObject({ code: 'DRAINING' }) @@ -1955,7 +1973,7 @@ describe('continuable durability and teardown', () => { ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) }) ctx.on('agent/inbox/inserted', ({ message }) => { accepted.push(message.id) }) - await expect(ctx.subagents.startContinuable(startSpec(parent))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })) .rejects.toMatchObject({ code: 'DRAINING' }) await Promise.all(drains) @@ -1978,7 +1996,7 @@ describe('continuable durability and teardown', () => { // `agent/created` runs after registry publication but before materialize() // receives the handle and installs the Activation. - await expect(ctx.subagents.startContinuable(startSpec(parent))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })) .rejects.toMatchObject({ code: 'DRAINING' }) await Promise.all(drains) @@ -1990,7 +2008,7 @@ describe('continuable durability and teardown', () => { 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 started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const order: string[] = [] @@ -2017,7 +2035,7 @@ describe('continuable durability and teardown', () => { 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)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) // Accepted into the inbox, but this queued turn never opens. await queuePrompt(ctx, parent, started.childId, message('never logged')) @@ -2041,7 +2059,7 @@ describe('continuable review regressions', () => { sessionId: parentId, agentOptions: { provider: 'mock', model: 'mock' }, }) - const started = await ctx.subagents.startContinuable(startSpec(originalParent.agent)) + const started = await ctx.subagents.startActivation({ ...startSpec(originalParent.agent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const ownerAgents = continuationActivations(ctx).ownerCtx.agents @@ -2085,7 +2103,7 @@ describe('continuable review regressions', () => { 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 started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const realFollowup = child.followup.bind(child) @@ -2115,7 +2133,7 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(1) }) @@ -2127,7 +2145,7 @@ describe('continuable review regressions', () => { 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)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const before = child.session.snapshotEvents().length @@ -2151,7 +2169,7 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: '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 @@ -2193,7 +2211,7 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(1) }) @@ -2206,7 +2224,7 @@ describe('continuable review regressions', () => { 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)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const ends: SubagentRunEndInfo[] = [] @@ -2231,14 +2249,14 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const activation = await vi.waitFor(() => { const found = continuationActivations(ctx).get(started.childId) expect(found).toBeDefined() return found! }) - const realDispose = activation.handle.dispose.bind(activation.handle) - activation.handle.dispose = async () => { + const realDispose = activation.driver.dispose.bind(activation.driver) + activation.driver.dispose = async () => { await realDispose() throw new Error('scoped cleanup failed') } @@ -2256,9 +2274,9 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', info => void ends.push(info)) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const activation = continuationActivations(ctx).get(started.childId)! - activation.observer.capture = () => { throw new Error('capture failed') } + activation.driver.capture = () => { throw new Error('capture failed') } const drained = drainManager(ctx) hold.resolve(undefined) @@ -2274,8 +2292,8 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', info => void ends.push(info)) - const started = await ctx.subagents.startContinuable(startSpec(parent)) - continuationActivations(ctx).get(started.childId)!.observer.capture = () => { + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) + continuationActivations(ctx).get(started.childId)!.driver.capture = () => { throw new Error('capture failed') } @@ -2289,11 +2307,11 @@ describe('continuable review regressions', () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const activation = continuationActivations(ctx).get(started.childId)! - const realDispose = activation.handle.dispose.bind(activation.handle) - activation.observer.capture = () => { throw new Error('capture failed') } - activation.handle.dispose = async () => { + const realDispose = activation.driver.dispose.bind(activation.driver) + activation.driver.capture = () => { throw new Error('capture failed') } + activation.driver.dispose = async () => { await realDispose() throw new Error('scoped cleanup failed') } @@ -2317,7 +2335,7 @@ describe('continuable review regressions', () => { if (session.header.parentSession !== undefined) order.push('flush') }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) expect(found).toBeDefined() @@ -2338,7 +2356,7 @@ describe('continuable review regressions', () => { 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 started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: '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. @@ -2357,7 +2375,7 @@ describe('continuable review regressions', () => { const release = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const messageId = await queuePrompt(ctx, parent, started.childId, message('queued')) @@ -2373,7 +2391,7 @@ describe('continuable review regressions', () => { const release = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! // Held while the child still runs, so the watcher observes idle and then @@ -2401,7 +2419,7 @@ describe('continuable review regressions', () => { const release = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const lock = await holdChildLock(ctx, started.childId) @@ -2427,7 +2445,7 @@ describe('continuable review regressions', () => { const release = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const lock = await holdChildLock(ctx, started.childId) @@ -2448,7 +2466,7 @@ describe('continuable review regressions', () => { const release = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! // A cordis-host-runner failure report: `steer()` from a plugin still wakes @@ -2471,7 +2489,7 @@ describe('continuable review regressions', () => { const release = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! await humanPrompt(ctx, parent, started.childId, 'steered', 'steer') @@ -2489,7 +2507,7 @@ describe('continuable review regressions', () => { 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)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! @@ -2514,7 +2532,7 @@ describe('continuable review regressions', () => { 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)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! @@ -2546,7 +2564,7 @@ describe('continuable review regressions', () => { return { kind: 'reject' } }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) // The parent would otherwise believe a vetoed delivery was done and never @@ -2571,7 +2589,7 @@ describe('continuable review regressions', () => { } }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId) await queuePrompt(ctx, parent, started.childId, message('queued')) @@ -2639,7 +2657,7 @@ describe('continuable adjacent-Agent delivery', () => { { chunks: textResponse('parent settlement ack') }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests.filter(request => request.sessionId === started.childId)).toHaveLength(1) }) @@ -2673,13 +2691,13 @@ describe('continuable adjacent-Agent delivery', () => { }) }) - it('rejects child-to-parent delivery when the direct parent is not live', async () => { + it.each(['absent', 'replaced'] as const)('rejects child-to-parent delivery when the direct parent is %s', async (state) => { const releaseChild = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('child answer'), gate: releaseChild.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) expect(found).toBeDefined() @@ -2687,7 +2705,7 @@ describe('continuable adjacent-Agent delivery', () => { }) const get = ctx.agents.get.bind(ctx.agents) const getSpy = vi.spyOn(ctx.agents, 'get').mockImplementation(id => ( - id === parent.id ? undefined : get(id) + id === parent.id ? state === 'absent' ? undefined : { ...parent } : get(id) )) await expect(ctx.subagents.sendMessage(child, parent.id, message('cannot arrive'), { @@ -2705,7 +2723,7 @@ describe('continuable adjacent-Agent delivery', () => { { chunks: textResponse('child answer'), gate: releaseChild.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) expect(found).toBeDefined() @@ -2724,17 +2742,16 @@ describe('continuable adjacent-Agent delivery', () => { }) describe('continuable settlement delivery', () => { - it('tells the parent what the child finished with, without being asked', async () => { + it('reports local completion without repeating the child answer', async () => { const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) const notice = settlementNotices(parent)[0]! expect(notice.sender).toBe(started.childId) expect(notice.text).toBe( - `Background subagent ${started.childId} finished and will do no further work unless you send it more.` - + '\nIts closing message:\nthe answer', + `Background subagent ${started.childId} finished and will do no further work unless you send it more.`, ) // The collapsed row states the outcome without the child's content. expect(notice.summary).toBe( @@ -2744,7 +2761,7 @@ describe('continuable settlement delivery', () => { it('delivers settlement even when the child already sent a message', async () => { const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const live = ctx.agents.get(started.childId) expect(live).toBeDefined() @@ -2763,13 +2780,12 @@ describe('continuable settlement delivery', () => { it('delivers the terminal reason when the child never had a chance to report', async () => { const { ctx, parent } = await setup([maxTokensResponse('half an ans'), textResponse('parent ack')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} ran out of room before it finished.` - + '\nIts closing message:\nhalf an ans', + `Background subagent ${started.childId} ran out of room before it finished.`, ) }) @@ -2782,13 +2798,12 @@ describe('continuable settlement delivery', () => { return { kind: 'reject' } }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} declined the task.` - + '\nIt left no closing message.', + `Background subagent ${started.childId} declined the task.`, ) }) @@ -2807,7 +2822,7 @@ describe('continuable settlement delivery', () => { throw new Error('ENOSPC: no space left on device') }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await queuePrompt(ctx, parent, started.childId, message('second task')) releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) @@ -2818,8 +2833,7 @@ describe('continuable settlement delivery', () => { const child = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(child.events, 'second task')).toBe(false) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} failed before it finished.` - + '\nIts closing message:\nthe answer', + `Background subagent ${started.childId} failed before it finished.`, ) }) @@ -2839,7 +2853,7 @@ describe('continuable settlement delivery', () => { return next() }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) // Queued while turn 1 still runs, so turn 2 opens and claims it without a // second model call: the Activation is mid-turn when the drain cancels it. await queuePrompt(ctx, parent, started.childId, message('second task')) @@ -2854,8 +2868,7 @@ describe('continuable settlement delivery', () => { // task had finished. await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.` - + '\nIts closing message:\nthe answer', + `Background subagent ${started.childId} was stopped before it finished.`, ) }) @@ -2870,7 +2883,7 @@ describe('continuable settlement delivery', () => { return next() }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await atCheckpoint.promise const drained = drainManager(ctx) releaseCheckpoint.resolve(undefined) @@ -2881,8 +2894,7 @@ describe('continuable settlement delivery', () => { // that genuinely finished with no output. await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.` - + '\nIt left no closing message.', + `Background subagent ${started.childId} was stopped before it finished.`, ) }) @@ -2898,7 +2910,7 @@ describe('continuable settlement delivery', () => { return next() }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await atCheckpoint.promise // The shipped interrupt path: nothing about it runs inside this manager, so // no pre-cancel sample could see it — the child's own log has to say so. @@ -2908,8 +2920,7 @@ describe('continuable settlement delivery', () => { await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.` - + '\nIt left no closing message.', + `Background subagent ${started.childId} was stopped before it finished.`, ) }) @@ -2922,7 +2933,7 @@ describe('continuable settlement delivery', () => { { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const live = ctx.agents.get(started.childId) expect(live).toBeDefined() @@ -2930,7 +2941,7 @@ describe('continuable settlement delivery', () => { }) // A descendant keeps the child resident once its own turn closes, so the // maintenance phase below is reachable without racing settlement. - const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' }) await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() }) releaseChild.resolve(undefined) await vi.waitFor(() => { expect(child.status).toBe('idle') }) @@ -2950,21 +2961,20 @@ describe('continuable settlement delivery', () => { expect(hasUserText(child.session.snapshotEvents(), 'never runs')).toBe(false) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.` - + '\nIts closing message:\nthe answer', + `Background subagent ${started.childId} was stopped before it finished.`, ) }) it('withholds an outcome the harness could not durably release', async () => { const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const activation = await vi.waitFor(() => { const live = continuationActivations(ctx).get(started.childId) expect(live).toBeDefined() return live! }) - const dispose = activation.handle.dispose.bind(activation.handle) - activation.handle.dispose = async () => { + const dispose = activation.driver.dispose.bind(activation.driver) + activation.driver.dispose = async () => { await dispose() throw new Error('scope unwind failed') } @@ -2972,7 +2982,7 @@ describe('continuable settlement delivery', () => { await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} failed before it finished.\nIt left no closing message.`, + `Background subagent ${started.childId} failed before it finished.`, ) }) @@ -2983,7 +2993,7 @@ describe('continuable settlement delivery', () => { if (session.id === parent.id && event.type === 'turn/start') turnStarts.push(event.data.turn) }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(1) @@ -3005,8 +3015,8 @@ describe('continuable settlement delivery', () => { parent.followup(createUserMessage({ content: message('start working'), source: { kind: 'user' } })) await vi.waitFor(() => { expect(parent.status).toBe('running') }) - const first = await ctx.subagents.startContinuable(startSpec(parent)) - const second = await ctx.subagents.startContinuable(startSpec(parent)) + const first = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) + const second = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) releaseChildren.resolve(undefined) await waitNoActivation(ctx, first.childId) await waitNoActivation(ctx, second.childId) @@ -3037,14 +3047,14 @@ describe('continuable settlement delivery', () => { { chunks: textResponse('root reacts') }, ]) const { ctx, parent } = await setupWith(adapter) - const outer = await ctx.subagents.startContinuable(startSpec(parent)) + const outer = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const middle = await vi.waitFor(() => { const live = ctx.agents.get(outer.childId) expect(live).toBeDefined() return live! }) - const first = await ctx.subagents.startContinuable(startSpec(middle)) - const second = await ctx.subagents.startContinuable(startSpec(middle)) + const first = await ctx.subagents.startActivation({ ...startSpec(middle), delivery: 'parent' }) + const second = await ctx.subagents.startActivation({ ...startSpec(middle), delivery: 'parent' }) await vi.waitFor(() => { expect(middle.status).toBe('idle') }) // `whenIdle()` follows maintenance and the deferred wake it releases, so @@ -3073,13 +3083,13 @@ describe('continuable settlement delivery', () => { { chunks: textResponse('outer reacts') }, ]) const { ctx, parent } = await setupWith(adapter) - const outer = await ctx.subagents.startContinuable(startSpec(parent)) + const outer = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const middle = await vi.waitFor(() => { const live = ctx.agents.get(outer.childId) expect(live).toBeDefined() return live! }) - const inner = await ctx.subagents.startContinuable(startSpec(middle)) + const inner = await ctx.subagents.startActivation({ ...startSpec(middle), delivery: 'parent' }) await vi.waitFor(() => { expect(middle.status).toBe('idle') }) let ownedAtDelivery: SessionId[] | undefined @@ -3100,7 +3110,7 @@ describe('continuable settlement delivery', () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('interrupted'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() }) const drained = drainManager(ctx) @@ -3112,8 +3122,7 @@ describe('continuable settlement delivery', () => { // message when that parent is disposed next is pinned by the test below. expect(settlementNotices(parent)).toHaveLength(1) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.` - + '\nIt left no closing message.', + `Background subagent ${started.childId} was stopped before it finished.`, ) expect(parent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced')).toBe(true) expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) @@ -3124,10 +3133,10 @@ describe('continuable settlement delivery', () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('interrupted'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() }) - const drained = ctx.subagents.drainContinuableDescendants([parent]) + const drained = ctx.subagents.drainDescendants([parent]) hold.resolve(undefined) await drained @@ -3144,10 +3153,10 @@ describe('continuable settlement delivery', () => { sessionId: parentId, agentOptions: { provider: 'mock', model: 'mock' }, }) - const started = await ctx.subagents.startContinuable(startSpec(host.agent)) + const started = await ctx.subagents.startActivation({ ...startSpec(host.agent), delivery: 'parent' }) await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() }) - const drained = ctx.subagents.drainContinuableDescendants([host.agent]) + const drained = ctx.subagents.drainDescendants([host.agent]) hold.resolve(undefined) await drained expect(settlementNotices(host.agent)).toHaveLength(1) @@ -3180,7 +3189,7 @@ describe('continuable settlement delivery', () => { sessionId: SessionId('disposable-parent'), agentOptions: { provider: 'mock', model: 'mock' }, }) - const started = await ctx.subagents.startContinuable(startSpec(host.agent)) + const started = await ctx.subagents.startActivation({ ...startSpec(host.agent), delivery: 'parent' }) const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) @@ -3201,7 +3210,7 @@ describe('continuable settlement delivery', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(1) }) expect(ends[0]!.stopReason).toBe('completed') @@ -3213,7 +3222,7 @@ describe('continuable settlement delivery', () => { const drains: Promise[] = [] ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) }) - await expect(ctx.subagents.startContinuable(startSpec(parent))) + await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })) .rejects.toMatchObject({ code: 'DRAINING' }) await Promise.all(drains) expect(settlementNotices(parent)).toEqual([]) @@ -3229,7 +3238,7 @@ describe('continuable lifecycle observation', () => { ctx.on('subagent/start', (info) => { starts.push(info) }) ctx.on('subagent/end', (info) => { ends.push(info) }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(1) }) @@ -3270,22 +3279,25 @@ describe('continuable public API', () => { 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', { - label: 'one-shot work', - prompt: message('one-shot work'), - parent, - signal: testSignal, - }) + it('keeps controls on the service while caller-awaited local work can resume', async () => { + const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) + parkParent(ctx, parent) + const run = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'caller' }) expect('steer' in run).toBe(false) - await run.result + expect((await run.result).output).toEqual(message('first answer')) await run.dispose() + await waitNoActivation(ctx, run.childId) + + await queuePrompt(ctx, parent, run.childId, message('continue')) + await waitNoActivation(ctx, run.childId) + const stored = await loadStoredSession(ctx.sessionPersistence, run.childId) + expect(userTexts(stored.events)).toEqual(['child task', 'continue']) + expect(stored.events.some(event => event.type === 'assistant/message' && JSON.stringify(event).includes('second answer'))).toBe(true) }) 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)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) const controller = new AbortController() @@ -3304,7 +3316,7 @@ describe('continuable public API', () => { { chunks: textResponse('second') }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const controller = new AbortController() @@ -3324,7 +3336,7 @@ describe('continuable errors', () => { 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 started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) expect(found).toBeDefined() @@ -3342,7 +3354,7 @@ describe('continuable errors', () => { 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 started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) expect(found).toBeDefined() @@ -3360,7 +3372,7 @@ describe('continuable errors', () => { 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 started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) expect(found).toBeDefined() @@ -3369,7 +3381,7 @@ describe('continuable errors', () => { // Begin the parent Activation's teardown, then try to give it a child. const drained = drainManager(ctx) - await expect(ctx.subagents.startContinuable(startSpec(child))) + await expect(ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' })) .rejects.toMatchObject({ code: 'DRAINING' }) hold.resolve(undefined) await drained @@ -3382,19 +3394,19 @@ describe('continuable errors', () => { { chunks: textResponse('grandchild'), gate: hold.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: '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)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' }) 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 branch = continuationActivations(ctx).get(grandchild.childId)! - const realDispose = branch.handle.dispose.bind(branch.handle) - branch.handle.dispose = async () => { + const realDispose = branch.driver.dispose.bind(branch.driver) + branch.driver.dispose = async () => { await realDispose() throw new Error('grandchild reap failed') } @@ -3415,7 +3427,7 @@ describe('continuable errors', () => { { chunks: textResponse('unused') }, ]) const { ctx, parent } = await setupWith(adapter) - const outer = await ctx.subagents.startContinuable(startSpec(parent)) + const outer = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(outer.childId) expect(found).toBeDefined() @@ -3426,7 +3438,7 @@ describe('continuable errors', () => { const before = new Set(ctx.agents.list().map(agent => agent.id)) void continuationActivations(ctx).get(outer.childId)!.inbox.close(() => Promise.resolve()) - await expect(ctx.subagents.startContinuable(startSpec(child))) + await expect(ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' })) .rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' }) await vi.waitFor(() => { expect(ctx.agents.list().map(agent => agent.id).filter(id => !before.has(id))).toEqual([]) @@ -3441,7 +3453,7 @@ describe('continuable errors', () => { { chunks: textResponse('unused') }, ]) const { ctx, parent } = await setupWith(adapter) - const outer = await ctx.subagents.startContinuable(startSpec(parent)) + const outer = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(outer.childId) expect(found).toBeDefined() @@ -3461,7 +3473,7 @@ describe('continuable errors', () => { return originalCreate(options) }) - await expect(ctx.subagents.startContinuable(startSpec(child))) + await expect(ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' })) .rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' }) await vi.waitFor(() => { expect(ctx.agents.list().map(agent => agent.id).filter(id => !before.has(id))).toEqual([]) @@ -3475,7 +3487,7 @@ describe('continuable errors', () => { { chunks: textResponse('child done'), gate: release.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) expect(found).toBeDefined() @@ -3499,13 +3511,13 @@ describe('continuable errors', () => { { chunks: textResponse('follow-up answer') }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: '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)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests.length).toBeGreaterThanOrEqual(2) }) // The delivering parent already owns this running child, so the entry hold @@ -3525,7 +3537,8 @@ describe('continuable errors', () => { defaultEffort: effort, }) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable({ + const started = await ctx.subagents.startActivation({ + delivery: 'parent', ...startSpec(parent), request: { prompt: message('routed work'), @@ -3573,7 +3586,7 @@ describe('continuable errors', () => { await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() }) // Manager unload uses the same drain, so no child outlives its runtime. @@ -3594,7 +3607,7 @@ describe('SubagentRuntime.interrupt', () => { { chunks: textResponse('fourth') }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! await queuePrompt(ctx, parent, started.childId, message('parked B')) @@ -3636,10 +3649,10 @@ describe('SubagentRuntime.interrupt', () => { { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! - const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) const grandchildAgent = ctx.agents.get(grandchild.childId)! const childCancel = vi.spyOn(child, 'cancel') @@ -3670,7 +3683,7 @@ describe('SubagentRuntime.interrupt', () => { 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 started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const cancelSpy = vi.spyOn(child, 'cancel') @@ -3695,10 +3708,10 @@ describe('SubagentRuntime.interrupt', () => { { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! - const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + const grandchild = await ctx.subagents.startActivation({ ...startSpec(child), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) const grandchildAgent = ctx.agents.get(grandchild.childId)! const childCancel = vi.spyOn(child, 'cancel') @@ -3725,10 +3738,10 @@ describe('SubagentRuntime.interrupt', () => { { chunks: textResponse('b'), gate: releaseB.promise }, ]) const { ctx, parent } = await setupWith(adapter) - const targetStart = await ctx.subagents.startContinuable(startSpec(parent)) + const targetStart = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const target = ctx.agents.get(targetStart.childId)! - const siblingStart = await ctx.subagents.startContinuable(startSpec(parent)) + const siblingStart = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) const sibling = ctx.agents.get(siblingStart.childId)! const stranger = await ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) @@ -3754,29 +3767,34 @@ describe('SubagentRuntime.interrupt', () => { await waitNoActivation(ctx, siblingStart.childId) }) - it('accepts absent and one-shot ids as no-ops without touching the one-shot Agent', async () => { - const { ctx, parent } = await setup([textResponse('one shot')]) + it('accepts absent ids and interrupts caller-awaited local activations', async () => { + const release = Promise.withResolvers() + const { ctx, parent } = await setupWith(new GatedAdapter([ + { chunks: textResponse('unfinished'), gate: release.promise }, + ])) ctx.subagents.interrupt(SessionId('missing'), { kind: 'user', parentSessionId: parent.id }) ctx.subagents.interrupt(SessionId('missing'), { kind: 'ancestor', agent: parent }) - const run = await ctx.subagents.start('spawn', { - label: 'one-shot work', - prompt: message('one-shot work'), - parent, - signal: testSignal, - }) - const oneShot = run.localAgent! - const cancelSpy = vi.spyOn(oneShot, 'cancel') - ctx.subagents.interrupt(run.id, { kind: 'user', parentSessionId: parent.id }) - ctx.subagents.interrupt(run.id, { kind: 'ancestor', agent: parent }) - expect(cancelSpy).not.toHaveBeenCalled() - await run.result - await run.dispose() + try { + const run = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'caller' }) + const child = ctx.agents.get(run.childId)! + const cancelSpy = vi.spyOn(child, 'cancel') + ctx.subagents.interrupt(run.childId, { kind: 'user', parentSessionId: parent.id }) + ctx.subagents.interrupt(run.childId, { kind: 'ancestor', agent: parent }) + expect(cancelSpy).toHaveBeenNthCalledWith(1, { kind: 'user' }, { keepInbox: true }) + expect(cancelSpy).toHaveBeenNthCalledWith(2, { kind: 'parent' }, { keepInbox: true }) + release.resolve(undefined) + expect((await run.result).stopReason).toBe('aborted') + await run.dispose() + } finally { + release.resolve(undefined) + await ctx.fiber.dispose() + } }) it('accepts an interrupt after natural completion', async () => { const { ctx, parent } = await setup([textResponse('done')]) - const started = await ctx.subagents.startContinuable(startSpec(parent)) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id }) ctx.subagents.interrupt(started.childId, { kind: 'ancestor', agent: parent }) @@ -3786,14 +3804,14 @@ describe('SubagentRuntime.interrupt', () => { 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 started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const cancelSpy = vi.spyOn(child, 'cancel') // Scoped teardown opens the disposal transaction synchronously and issues // its own whole-Activation cancel before this call returns. - const drained = ctx.subagents.drainContinuableDescendants([parent]) + const drained = ctx.subagents.drainDescendants([parent]) expect(cancelSpy).toHaveBeenCalledTimes(1) // Interrupt after the cutoff: accepted no-op, no second signal, no waiting. diff --git a/packages/subagent/subagent/tests/external-activation-helpers.ts b/packages/subagent/subagent/tests/external-activation-helpers.ts new file mode 100644 index 0000000000..389ce804d6 --- /dev/null +++ b/packages/subagent/subagent/tests/external-activation-helpers.ts @@ -0,0 +1,76 @@ +/** Registered parent fixtures for external backend activation tests. */ + +import { randomUUID } from 'node:crypto' +import { onTestFinished } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' +import type { SubagentActivation, SubagentStartRequest } from '../src/types.ts' + +const parents = new WeakMap>>() + +/** Mount only the registries needed by an external child's parent; no model is driven. */ +function parentStore(ctx: Context): Promise> { + const existing = parents.get(ctx) + if (existing !== undefined) return existing + onTestFinished(() => ctx.fiber.dispose()) + const ready = (async () => { + if (ctx.get('sessions') === undefined) await ctx.plugin(SessionStore) + if (ctx.get('agents') === undefined) await ctx.plugin(AgentRegistry) + return new WeakMap() + })() + parents.set(ctx, ready) + return ready +} + +/** Register a parent with a real Session and an observable, non-driving Inbox. */ +export async function externalTestParent(ctx: Context, cwd?: string): Promise { + await parentStore(ctx) + const session = ctx.sessions.create(SessionId(randomUUID()), { + meta: cwd === undefined ? {} : { cwd }, + }) + const inbox = createInboxStub() + const parent: Agent = { + id: session.id, + session, + ctx, + options: {}, + status: 'idle', + inbox, + send: (message, target) => { inbox.append(target, message) }, + followup: (message) => { inbox.append('next-turn', message) }, + steer: (message) => { inbox.append('next-step', message) }, + inject: (message) => { inbox.append('next-step', message) }, + cancel: (_cause, options) => { if (!options?.keepInbox) inbox.clear() }, + whenIdle: () => Promise.resolve(), + runMaintenance: task => task(new AbortController().signal), + } + ctx.agents.register(parent) + return parent +} + +/** Start through the activation service while preserving a backend fixture's workspace input. */ +export async function startExternalActivation( + ctx: Context, + provider: string, + request: SubagentStartRequest, +): Promise { + const registry = await parentStore(ctx) + let parent = ctx.agents.get(request.parent.id) === request.parent + ? request.parent + : registry.get(request.parent) + if (parent === undefined) { + parent = await externalTestParent(ctx, request.parent.session.header.cwd) + registry.set(request.parent, parent) + } + const { label, signal, ...execution } = request + return ctx.subagents.startActivation({ + provider, + label: label ?? 'External test task', + request: { ...execution, parent }, + signal, + delivery: 'caller', + }) +} diff --git a/packages/subagent/subagent/tests/external-activation.spec.ts b/packages/subagent/subagent/tests/external-activation.spec.ts new file mode 100644 index 0000000000..a1fd1dd6ce --- /dev/null +++ b/packages/subagent/subagent/tests/external-activation.spec.ts @@ -0,0 +1,244 @@ +/** External execution ownership, result delivery, and process cleanup. */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SubagentRuntime from '../src/index.ts' +import type { ResolvedSubagentStartRequest, SubagentCapabilities, SubagentResult, SubagentRun } from '../src/types.ts' +import { externalTestParent } from './external-activation-helpers.ts' +import { continuationManager } from './continuation-internals.ts' + +const capabilities: SubagentCapabilities = { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false, +} + +async function setup(start: (request: ResolvedSubagentStartRequest) => Promise) { + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SubagentRuntime) + const parent = await externalTestParent(ctx) + ctx.subagents.registerProvider({ name: 'external', capabilities, inheritsParentContext: false, start }) + const controller = new AbortController() + return { + ctx, parent, controller, + start: (delivery: 'parent' | 'caller' = 'caller') => ctx.subagents.startActivation({ + provider: 'external', label: 'External work', signal: controller.signal, delivery, + request: { parent, prompt: [{ type: 'text', text: 'Work independently' }] }, + }), + records: () => ctx.sessionProjections.snapshot(parent.session, ['subagentExternal']).values.subagentExternal, + } +} + +function execution() { + const result = Promise.withResolvers() + const cleanup = Promise.withResolvers() + const cleaning = Promise.withResolvers() + const dispose = vi.fn(() => { cleaning.resolve(undefined); return cleanup.promise }) + const run: SubagentRun = { id: SessionId('external-task'), localAgent: undefined, result: result.promise, dispose } + return { result, cleanup, cleaning, dispose, run } +} + +const completed: SubagentResult = { + output: [{ type: 'text', text: 'Reviewed three files.' }], + structured: { files: 3 }, + stopReason: 'completed', +} + +describe('external subagent activations', () => { + it('keeps startup cancellation attached until unpublished resources finish cleanup', async () => { + const entered = Promise.withResolvers() + const cancelled = Promise.withResolvers() + const cleanup = Promise.withResolvers() + const fixture = await setup(async ({ signal }) => { + entered.resolve(signal) + signal.addEventListener('abort', () => { cancelled.resolve(undefined) }, { once: true }) + await cancelled.promise + await cleanup.promise + signal.throwIfAborted() + throw new Error('cancelled startup unexpectedly continued') + }) + const starting = fixture.start() + const rejected = expect(starting).rejects.toThrow('cancel startup') + const providerSignal = await entered.promise + let settled = false + void starting.then(() => { settled = true }, () => { settled = true }) + fixture.controller.abort(new Error('cancel startup')) + await cancelled.promise + expect(providerSignal.aborted).toBe(true) + expect(settled).toBe(false) + expect(fixture.records()).toEqual([]) + cleanup.resolve(undefined) + await rejected + expect(fixture.parent.inbox.nextTurn).toEqual([]) + }) + + it.each(['drain', 'parent-detach', 'parent-replace'] as const)( + 'rechecks publication authority after a start listener triggers %s', + async (change) => { + const backend = execution() + backend.cleanup.resolve(undefined) + const fixture = await setup(async ({ signal }) => { + signal.addEventListener('abort', () => { backend.result.resolve({ output: [], stopReason: 'aborted' }) }, { once: true }) + return backend.run + }) + const session = fixture.ctx.sessions.create(SessionId('publication-parent')) + const parent = { ...fixture.parent, id: session.id, session } + const detachParent = fixture.ctx.agents.enter(parent, undefined) + let detachReplacement: (() => void) | undefined + let drain = Promise.resolve() + fixture.ctx.on('subagent/start', () => { + if (change === 'drain') { + drain = continuationManager(fixture.ctx).drain() + } else { + detachParent() + if (change === 'parent-replace') { + detachReplacement = fixture.ctx.agents.enter({ ...parent }, undefined) + } + } + }) + try { + await expect(fixture.ctx.subagents.startActivation({ + provider: 'external', label: 'Unpublished work', signal: fixture.controller.signal, delivery: 'parent', + request: { parent, prompt: [{ type: 'text', text: 'Must not publish' }] }, + })).rejects.toMatchObject({ code: change === 'drain' ? 'DRAINING' : 'UNAUTHORIZED' }) + await drain + expect(backend.dispose).toHaveBeenCalledTimes(1) + expect(fixture.ctx.sessionProjections.snapshot(session, ['subagentExternal']).values.subagentExternal).toEqual([]) + expect(parent.inbox.nextTurn).toEqual([]) + } finally { + detachReplacement?.() + detachParent() + } + }, + ) + + it('detaches accepted work from caller cancellation and exposes its result before cleanup finishes', async () => { + const backend = execution() + let providerSignal: AbortSignal | undefined + const fixture = await setup(async ({ signal }) => { providerSignal = signal; return backend.run }) + const activation = await fixture.start() + fixture.controller.abort(new Error('caller stopped waiting')) + expect(providerSignal?.aborted).toBe(false) + expect(backend.dispose).not.toHaveBeenCalled() + expect(fixture.ctx.sessions.get(activation.childId)).toBeUndefined() + backend.result.resolve(completed) + await expect(activation.result).resolves.toEqual(completed) + await backend.cleaning.promise + const disposal = activation.dispose() + expect(activation.dispose()).toBe(disposal) + let cleaned = false + void disposal.then(() => { cleaned = true }) + await Promise.resolve() + expect(cleaned).toBe(false) + expect(backend.dispose).toHaveBeenCalledTimes(1) + backend.cleanup.resolve(undefined) + await disposal + await activation.dispose() + expect(backend.dispose).toHaveBeenCalledTimes(1) + expect(fixture.records()).toMatchObject([{ childId: activation.childId, result: completed }]) + expect(fixture.parent.inbox.nextTurn).toEqual([]) + }) + + it('cancels the external process once when the receipt is repeatedly disposed', async () => { + const backend = execution() + const cancelled = vi.fn(() => { backend.result.resolve({ output: [], stopReason: 'aborted' }) }) + const fixture = await setup(async ({ signal }) => { + signal.addEventListener('abort', cancelled, { once: true }) + return backend.run + }) + const activation = await fixture.start() + const disposal = activation.dispose() + expect(activation.dispose()).toBe(disposal) + await backend.cleaning.promise + expect(cancelled).toHaveBeenCalledTimes(1) + expect(backend.dispose).toHaveBeenCalledTimes(1) + backend.cleanup.resolve(undefined) + await disposal + await expect(activation.result).resolves.toEqual({ output: [], stopReason: 'aborted' }) + await activation.dispose() + expect(cancelled).toHaveBeenCalledTimes(1) + }) + + it('can cancel a backend whose result settles only when disposal begins', async () => { + const result = Promise.withResolvers() + const dispose = vi.fn(async () => { result.resolve({ output: [], stopReason: 'aborted' }) }) + const fixture = await setup(async () => ({ + id: SessionId('dispose-settled'), localAgent: undefined, result: result.promise, dispose, + })) + const activation = await fixture.start() + await activation.dispose() + await expect(activation.result).resolves.toEqual({ output: [], stopReason: 'aborted' }) + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it.each(['parent', 'caller'] as const)('retains the full failed result with %s delivery', async (delivery) => { + const backend = execution() + backend.cleanup.resolve(undefined) + const fixture = await setup(async () => backend.run) + const activation = await fixture.start(delivery) + const failed: SubagentResult = { ...completed, stopReason: 'error', diagnostic: 'External transport failed.' } + backend.result.resolve(failed) + await expect(activation.result).resolves.toEqual(failed) + await activation.dispose() + expect(fixture.records()).toMatchObject([{ childId: activation.childId, result: failed }]) + if (delivery === 'caller') { + expect(fixture.parent.inbox.nextTurn).toEqual([]) + } else { + expect(fixture.parent.inbox.nextTurn).toHaveLength(1) + const notice = fixture.parent.inbox.nextTurn[0]! + expect(notice.source).toMatchObject({ kind: 'subagent-settled', senderSessionId: activation.childId }) + const text = notice.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n') + expect(text).toContain('Reviewed three files.') + expect(text).toContain('Structured result: {"files":3}') + expect(text).toContain('External transport failed.') + expect(text).not.toContain('follow-up') + } + }) + + it('reports a durable result write failure and releases capacity', async () => { + const backend = execution() + backend.cleanup.resolve(undefined) + const fixture = await setup(async () => backend.run) + const activation = await fixture.start('parent') + const append = fixture.parent.session.append.bind(fixture.parent.session) + const spy = vi.spyOn(fixture.parent.session, 'append').mockImplementation((...args) => { + if (args[0] === 'subagent/external-end') throw new Error('storage unavailable') + return Reflect.apply(append, fixture.parent.session, args) as ReturnType + }) + try { + backend.result.resolve(completed) + await expect(activation.result).resolves.toEqual(completed) + await expect(activation.dispose()).rejects.toThrow('result could not be recorded') + expect(continuationManager(fixture.ctx).isActive(activation.childId)).toBe(false) + expect(fixture.records()).toMatchObject([{ childId: activation.childId }]) + expect(fixture.parent.inbox.nextTurn[0]?.content).toEqual([ + { type: 'text', text: `Background subagent ${activation.childId} failed before it finished.` }, + { type: 'text', text: 'It left no closing message.' }, + ]) + } finally { + spy.mockRestore() + } + }) + + it('reports cleanup failure separately from a completed result without exposing the private cause', async () => { + const backend = execution() + const fixture = await setup(async () => backend.run) + const activation = await fixture.start() + backend.result.resolve(completed) + await expect(activation.result).resolves.toEqual(completed) + await backend.cleaning.promise + const disposal = activation.dispose() + const rejection = expect(disposal).rejects.toThrow('safe cleanup failure') + backend.cleanup.reject(new Error('safe cleanup failure', { cause: new Error('/private/path SECRET_TOKEN') })) + await rejection + const error = await disposal.catch((error: unknown) => error) + expect((error as Error).message).not.toContain('SECRET_TOKEN') + expect((error as Error).message).not.toContain('/private/path') + await expect(activation.result).resolves.toEqual(completed) + expect(activation.dispose()).toBe(disposal) + expect(fixture.records()).toMatchObject([{ + childId: activation.childId, result: { ...completed, stopReason: 'error' }, + }]) + }) +}) diff --git a/packages/subagent/subagent/tests/external-lifecycle.spec.ts b/packages/subagent/subagent/tests/external-lifecycle.spec.ts new file mode 100644 index 0000000000..48392e78a4 --- /dev/null +++ b/packages/subagent/subagent/tests/external-lifecycle.spec.ts @@ -0,0 +1,117 @@ +/** External activation admission and resource ownership across parent lifetimes. */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SubagentRuntime from '../src/index.ts' +import type { ResolvedSubagentStartRequest, SubagentRun, SubagentResult } from '../src/types.ts' +import { externalTestParent } from './external-activation-helpers.ts' + +const complete: SubagentResult = { output: [], stopReason: 'completed' } + +async function setup(start: (request: ResolvedSubagentStartRequest) => Promise) { + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SubagentRuntime) + const parent = await externalTestParent(ctx) + ctx.subagents.registerProvider({ + name: 'external', inheritsParentContext: false, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + start, + }) + return { + ctx, parent, + start: (owner: Agent = parent) => ctx.subagents.startActivation({ + provider: 'external', label: 'External work', signal: new AbortController().signal, delivery: 'caller', + request: { parent: owner, prompt: [{ type: 'text', text: 'work' }] }, + }), + } +} + +function run(id: string, result: Promise, dispose = () => Promise.resolve()): SubagentRun { + return { id: SessionId(id), result, localAgent: undefined, dispose } +} + +describe('external activation ownership', () => { + it('observes admitted startup and reports no children after the execution releases', async () => { + const entered = Promise.withResolvers() + const published = Promise.withResolvers() + const fixture = await setup(() => { entered.resolve(undefined); return published.promise }) + await expect(fixture.ctx.subagents.waitForChildren(fixture.parent)).resolves.toBe(false) + const starting = fixture.start() + await entered.promise + const waiting = fixture.ctx.subagents.waitForChildren(fixture.parent) + let released = false + void waiting.then(() => { released = true }) + await Promise.resolve() + expect(released).toBe(false) + published.resolve(run('admitted-start', Promise.resolve(complete))) + const activation = await starting + await waiting + await activation.dispose() + await expect(fixture.ctx.subagents.waitForChildren(fixture.parent)).resolves.toBe(false) + }) + + it('drains one parent’s external child without cancelling another parent’s pending startup', async () => { + const result = Promise.withResolvers() + const startingSibling = Promise.withResolvers() + const sibling = Promise.withResolvers() + let starts = 0 + let siblingSignal: AbortSignal | undefined + const fixture = await setup(({ signal }) => { + if (starts++ === 0) { + signal.addEventListener('abort', () => { result.resolve({ output: [], stopReason: 'aborted' }) }, { once: true }) + return Promise.resolve(run('owned-child', result.promise)) + } + siblingSignal = signal + startingSibling.resolve(undefined) + return sibling.promise + }) + const otherParent = await externalTestParent(fixture.ctx) + const first = await fixture.start() + const other = fixture.start(otherParent) + await startingSibling.promise + await fixture.ctx.subagents.drainDescendants([fixture.parent]) + await expect(first.result).resolves.toMatchObject({ stopReason: 'aborted' }) + expect(siblingSignal?.aborted).toBe(false) + sibling.resolve(run('sibling-child', Promise.resolve(complete))) + const second = await other + await second.dispose() + }) + + it('cleans an external execution whose id collides with a resident Session', async () => { + const dispose = vi.fn(() => Promise.resolve()) + const fixture = await setup(() => Promise.resolve(run('occupied-id', Promise.resolve(complete), dispose))) + fixture.ctx.sessions.create(SessionId('occupied-id')) + await expect(fixture.start()).rejects.toMatchObject({ code: 'DUPLICATE_CHILD' }) + expect(dispose).toHaveBeenCalledTimes(1) + expect(fixture.ctx.sessionProjections.snapshot(fixture.parent.session, ['subagentExternal']).values.subagentExternal).toEqual([]) + }) + + it('rejects messages to a resident external activation without affecting its result', async () => { + const result = Promise.withResolvers() + const fixture = await setup(() => Promise.resolve(run('no-continuation', result.promise))) + const activation = await fixture.start() + await expect(fixture.ctx.subagents.sendMessage(fixture.parent, activation.childId, [{ type: 'text', text: 'follow up' }], { + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'NOT_CONTINUABLE' }) + result.resolve(complete) + await activation.dispose() + }) + + it('contains a non-Error backend cleanup rejection and retains the execution result', async () => { + const result = Promise.withResolvers() + const cleanup = Promise.withResolvers() + const fixture = await setup(() => Promise.resolve(run('failed-cleanup', result.promise, () => cleanup.promise))) + const activation = await fixture.start() + result.resolve(complete) + await expect(activation.result).resolves.toEqual(complete) + const disposal = activation.dispose() + const rejected = expect(disposal).rejects.toThrow('unknown teardown failure') + cleanup.reject({ private: 'SECRET_TOKEN' }) + await rejected + expect((await disposal.catch((error: unknown) => error) as Error).message).not.toContain('SECRET_TOKEN') + }) +}) diff --git a/packages/subagent/subagent/tests/external-records.spec.ts b/packages/subagent/subagent/tests/external-records.spec.ts new file mode 100644 index 0000000000..8def4b8b61 --- /dev/null +++ b/packages/subagent/subagent/tests/external-records.spec.ts @@ -0,0 +1,321 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionCheckpoint } from '@deepseek-ai/dsh-session-projection' +import SessionProjectionCache from '@deepseek-ai/dsh-session-projection-cache' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import { externalSubagentProjectionDefinition, recordExternalEnd, recordExternalStart } from '../src/external-records.ts' +import { subagentIdentityProjectionDefinition } from '../src/projection.ts' +import { SUBAGENT_DESCRIPTOR_VERSION } from '../src/descriptor.ts' +import SubagentRuntime from '../src/index.ts' +import { externalTestParent } from './external-activation-helpers.ts' +import { listChildren, listDescendants } from '../src/list-children.ts' +import { catalogView } from '../src/control.ts' +import { TestSessionQuery } from './test-session-query.ts' +import { seedStoredSession } from './persistence-helpers.ts' + +const contexts: Context[] = [] +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }))) +}) + +async function setup(externalProjection = true) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(TestSessionQuery) + ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + if (externalProjection) ctx.sessionProjections.register(externalSubagentProjectionDefinition) + return ctx +} + +async function coldReader(sessions: readonly Session[], externalProjection = true) { + const reader = await setup(externalProjection) + const root = await mkdtemp(join(tmpdir(), 'dsh-external-cold-')) + roots.push(root) + await reader.plugin(JsonlSessionPersistence, { root }) + for (const session of sessions) { + await seedStoredSession(reader.sessionPersistence, session.header, session.snapshotEvents()) + } + return reader +} + +describe('parent-owned external subagent records', () => { + it('lists an accepted external child without a child Session and retains its failed result', async () => { + const ctx = await setup() + const parent = ctx.sessions.create(SessionId('external-parent')) + const childId = SessionId('external-child') + recordExternalStart(parent, childId, 'codex', 'Review changes') + expect(ctx.sessions.get(childId)).toBeUndefined() + const running = [{ + kind: 'child', id: childId, mode: 'one-shot', label: 'Review changes', + external: true, activity: 'inactive', hasChildren: false, + }] + await expect(listChildren(ctx, parent.id)).resolves.toEqual(running) + expect(catalogView(ctx, parent.id, await listChildren(ctx, parent.id)).entries).toEqual(running) + + const result = { + output: [{ type: 'text' as const, text: 'Partial review' }], + structured: { files: ['a.ts'], complete: false }, + diagnostic: 'provider transport failed', + stopReason: 'error' as const, + } + recordExternalEnd(parent, childId, result) + await expect(listChildren(ctx, parent.id)).resolves.toEqual([{ ...running[0], activity: 'inactive' }]) + const records = ctx.sessionProjections.snapshot(parent, ['subagentExternal']).values.subagentExternal + expect(records).toMatchObject([{ + childId, provider: 'codex', label: 'Review changes', result, + }]) + // Rebuild from serialized parent facts, without any resident execution or notice. + const events = JSON.parse(JSON.stringify(parent.snapshotEvents())) as SessionEvent[] + const restored = ctx.sessionProjections.restore({}, events, SessionLogOffset(0), parent.header, SessionLogOffset(0)) + expect(restored.snapshot.values.subagentExternal).toEqual(records) + const checkpoint = JSON.parse(JSON.stringify(restored.checkpoint)) as ProjectionCheckpoint + expect(ctx.sessionProjections.restore(checkpoint, [], parent.seq, parent.header, SessionLogOffset(0)) + .snapshot.values.subagentExternal).toEqual(records) + }) + + it('rejects non-JSON structured results before appending a terminal record', async () => { + const ctx = await setup() + const parent = ctx.sessions.create(SessionId('non-json-parent')) + const childId = SessionId('non-json-child') + recordExternalStart(parent, childId, 'external', 'Work') + const seq = parent.seq + expect(() => { recordExternalEnd(parent, childId, { + output: [], structured: new Map([['count', 3]]), stopReason: 'completed', + }) }).toThrow() + expect(parent.seq).toBe(seq) + expect(ctx.sessionProjections.snapshot(parent, ['subagentExternal']).values.subagentExternal) + .toMatchObject([{ childId, provider: 'external', label: 'Work' }]) + }) + + it('restores nested content and extension fields without flattening them to text', async () => { + const ctx = await setup() + const parent = ctx.sessions.create(SessionId('json-block-parent')) + const childId = SessionId('json-block-child') + recordExternalStart(parent, childId, 'external', 'Work') + const output = [{ + type: 'product-result', metadata: { citations: [{ url: 'https://example.com', scores: [1, 2] }] }, + content: [{ type: 'tool-result', toolCallId: 'call-1', content: [{ type: 'text', text: 'Nested output' }] }], + }] + const events: SessionEvent[] = [ + ...parent.snapshotEvents(), + { type: 'subagent/external-end', seq: SessionSeq(parent.seq), time: 12, data: { + version: 0, childId, result: { output, structured: { nested: [true, null] }, stopReason: 'completed' }, + } }, + ] + const restored = ctx.sessionProjections.restore({}, events, SessionLogOffset(0), parent.header, SessionLogOffset(0)) + expect(restored.snapshot.values.subagentExternal).toMatchObject([{ + result: { output, structured: { nested: [true, null] }, stopReason: 'completed' }, + }]) + }) + + it('discovers external descendants through a cold parent using one shared parent observation', async () => { + const authored = await setup() + const root = authored.sessions.create(SessionId('tree-root')) + const parent = authored.sessions.create(SessionId('tree-parent'), { + meta: { parentSession: root.id, origin: 'subagent' }, + }) + parent.append('subagent/descriptor', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 'spawn', label: 'Local parent' }) + const childId = SessionId('tree-external') + recordExternalStart(parent, childId, 'claude-code', 'External leaf') + recordExternalEnd(parent, childId, { output: [], structured: null, stopReason: 'completed' }) + + const reader = await setup() + const path = await mkdtemp(join(tmpdir(), 'dsh-external-records-')) + roots.push(path) + await reader.plugin(JsonlSessionPersistence, { root: path }) + for (const session of [root, parent]) { + await seedStoredSession(reader.sessionPersistence, session.header, session.snapshotEvents()) + } + const observe = vi.spyOn(reader.sessionQuery, 'observeSession') + expect(reader.sessions.get(parent.id)).toBeUndefined() + expect(reader.sessions.get(childId)).toBeUndefined() + const entries = await listDescendants(reader, root.id) + expect(entries).toEqual([ + { + kind: 'child', id: parent.id, mode: 'continuable', label: 'Local parent', + activity: 'inactive', hasChildren: true, parentId: root.id, depth: 1, + }, + { + kind: 'child', id: childId, mode: 'one-shot', label: 'External leaf', external: true, + activity: 'inactive', hasChildren: false, parentId: parent.id, depth: 2, + }, + ]) + expect(observe.mock.calls.filter(([id]) => id === parent.id)).toHaveLength(1) + expect(observe.mock.calls.some(([id]) => id === childId)).toBe(false) + await expect(listChildren(reader, parent.id)).resolves.toEqual([{ + kind: 'child', id: childId, mode: 'one-shot', label: 'External leaf', external: true, + activity: 'inactive', hasChildren: false, + }]) + }) + + it('returns an empty tree for an unknown parent or a composition without external records', async () => { + const ctx = await setup(false) + await expect(listChildren(ctx, SessionId('absent'))).resolves.toEqual([]) + await expect(listDescendants(ctx, SessionId('absent'))).resolves.toEqual([]) + const parent = ctx.sessions.create(SessionId('local-only-parent')) + const child = ctx.sessions.create(SessionId('local-only-child'), { meta: { parentSession: parent.id, origin: 'subagent' } }) + child.append('subagent/descriptor', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 'spawn', label: 'Local only' }) + await expect(listDescendants(ctx, parent.id)).resolves.toMatchObject([{ id: child.id, hasChildren: false }]) + }) + + it.each(['hit', 'miss', 'damaged'] as const)('reads a cold external catalog through a %s cache', async (cacheState) => { + const authored = await setup() + const parent = authored.sessions.create(SessionId('cached-parent')) + recordExternalStart(parent, SessionId('cached-external'), 'codex', 'Cached leaf') + const snapshot = authored.sessionProjections.snapshot(parent, ['subagentExternal']) + const reader = await coldReader([parent]) + const cacheRoot = await mkdtemp(join(tmpdir(), 'dsh-external-cache-')) + roots.push(cacheRoot) + await reader.plugin(Storage) + await reader.plugin(StorageJson, { root: cacheRoot }) + await reader.plugin(StorageDomain, { backend: 'json' }) + await reader.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + const cachedSnapshot = vi.spyOn(reader.sessionProjectionCache, 'cachedSnapshot').mockImplementation(() => { + if (cacheState === 'damaged') throw new Error('damaged derived checkpoint') + return cacheState === 'hit' ? snapshot : undefined + }) + const observe = vi.spyOn(reader.sessionQuery, 'observeSession') + await expect(listChildren(reader, parent.id, new AbortController().signal)).resolves.toMatchObject([ + { id: SessionId('cached-external'), external: true, label: 'Cached leaf' }, + ]) + expect(cachedSnapshot).toHaveBeenCalled() + expect(observe).toHaveBeenCalledTimes(cacheState === 'hit' ? 0 : 1) + }) + + it('rejects a replaced root parent and isolates an unreadable nested parent', async () => { + const authored = await setup() + const root = authored.sessions.create(SessionId('cold-root')) + const parent = authored.sessions.create(SessionId('cold-child'), { meta: { parentSession: root.id, origin: 'subagent' } }) + parent.append('subagent/descriptor', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 'spawn', label: 'Unavailable branch' }) + recordExternalStart(parent, SessionId('hidden-external'), 'codex', 'Hidden leaf') + const reader = await coldReader([root, parent]) + const read = reader.sessionQuery.observeSession.bind(reader.sessionQuery) + const observe = vi.spyOn(reader.sessionQuery, 'observeSession').mockImplementation(async (id, options) => { + if (id === parent.id) throw new Error('disk unavailable') + return read(id, options) + }) + await expect(listDescendants(reader, root.id)).resolves.toEqual([ + { kind: 'diagnostic', id: parent.id, reason: 'unavailable', parentId: root.id, depth: 1 }, + ]) + observe.mockImplementation(async (id, options) => { + const observation = await read(id, options) + return { ...observation, header: { ...observation.header, createdAt: observation.header.createdAt + 1 } } + }) + await expect(listChildren(reader, root.id)).rejects.toMatchObject({ code: 'SUBAGENT_CONTROL_PARENT_CHANGED' }) + }) + + it('propagates root read failures and cancellation instead of hiding an unknown external catalog', async () => { + const authored = await setup() + const parent = authored.sessions.create(SessionId('failing-root')) + const reader = await coldReader([parent]) + const failure = new Error('storage unavailable') + const observe = vi.spyOn(reader.sessionQuery, 'observeSession').mockRejectedValue(failure) + await expect(listChildren(reader, parent.id)).rejects.toBe(failure) + const controller = new AbortController() + observe.mockImplementation(() => { + controller.abort() + return Promise.reject(failure) + }) + await expect(listChildren(reader, parent.id, controller.signal)).rejects.toMatchObject({ code: 'CANCELLED' }) + }) + + it('reads local-only cold projections and accepts a query without optional projections', async () => { + const authored = await setup() + const root = authored.sessions.create(SessionId('optional-root')) + const child = authored.sessions.create(SessionId('optional-local'), { meta: { parentSession: root.id, origin: 'subagent' } }) + child.append('subagent/descriptor', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 'spawn', label: 'Local only' }) + const reader = await coldReader([root, child], false) + await expect(listDescendants(reader, root.id)).resolves.toMatchObject([{ id: child.id, hasChildren: false }]) + const read = reader.sessionQuery.observeSession.bind(reader.sessionQuery) + vi.spyOn(reader.sessionQuery, 'observeSession').mockImplementation(async (id, options) => { + const observation = await read(id, options) + const withoutProjections = { ...observation, [Symbol.dispose]: () => { observation[Symbol.dispose]() } } + delete withoutProjections.projections + return withoutProjections + }) + await expect(listChildren(reader, child.id)).resolves.toEqual([]) + }) + + it('orders external leaves with equal creation times by identity', async () => { + const authored = await setup() + const root = authored.sessions.create(SessionId('ordered-root')) + recordExternalStart(root, SessionId('z-external'), 'codex', 'Last') + recordExternalStart(root, SessionId('a-external'), 'codex', 'First') + const reader = await coldReader([]) + await seedStoredSession(reader.sessionPersistence, root.header, root.snapshotEvents().map(event => ({ ...event, time: 7 }))) + await expect(listDescendants(reader, root.id)).resolves.toMatchObject([ + { id: SessionId('a-external'), parentId: root.id, depth: 1 }, + { id: SessionId('z-external'), parentId: root.id, depth: 1 }, + ]) + }) + + it('samples external activity from the live manager and retains the row after disposal', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SubagentRuntime) + const parent = await externalTestParent(ctx) + await ctx.plugin(TestSessionQuery) + const terminal = Promise.withResolvers<{ output: []; stopReason: 'aborted' }>() + ctx.subagents.registerProvider({ + name: 'external', inheritsParentContext: false, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + start: () => Promise.resolve({ + id: SessionId('live-external'), localAgent: undefined, result: terminal.promise, + dispose: () => { terminal.resolve({ output: [], stopReason: 'aborted' }); return Promise.resolve() }, + }), + }) + const activation = await ctx.subagents.startActivation({ + provider: 'external', label: 'Live work', request: { parent, prompt: [{ type: 'text', text: 'Work' }] }, + signal: new AbortController().signal, delivery: 'caller', + }) + await expect(ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([{ id: activation.childId, activity: 'running' }]) + await expect(ctx.subagents.listDescendants(parent.id)).resolves.toMatchObject([{ id: activation.childId, activity: 'running', depth: 1 }]) + await activation.dispose() + await expect(ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([{ id: activation.childId, activity: 'inactive' }]) + }) + + it('does not inherit another parent’s external children across a fork', async () => { + const ctx = await setup() + const parent = ctx.sessions.create(SessionId('fork-source')) + recordExternalStart(parent, SessionId('ancestor-external'), 'codex', 'Ancestor work') + recordExternalEnd(parent, SessionId('ancestor-external'), { output: [], stopReason: 'aborted' }) + const inherited = parent.snapshotEvents() + const own: SessionEvent = { + type: 'subagent/external-start', seq: SessionSeq(inherited.length), time: 5, + data: { version: 0, childId: SessionId('own-external'), provider: 'codex', label: 'Own work' }, + } + const restored = ctx.sessionProjections.restore({}, [...inherited, own], SessionLogOffset(0), + { ...parent.header, id: SessionId('fork-child'), isSeeded: true }, SessionLogOffset(inherited.length)) + expect(restored.snapshot.values.subagentExternal).toEqual([{ + childId: SessionId('own-external'), provider: 'codex', label: 'Own work', createdAt: 5, + }]) + }) + + it.each(['orphan', 'duplicate-start', 'duplicate-end'] as const)('rejects %s execution records on restoration', async (fault) => { + const ctx = await setup() + const parent = ctx.sessions.create(SessionId(`invalid-${fault}`)) + const childId = SessionId('invalid-child') + if (fault !== 'orphan') recordExternalStart(parent, childId, 'codex', 'Work') + if (fault === 'duplicate-start') recordExternalStart(parent, childId, 'codex', 'Work') + else { + recordExternalEnd(parent, childId, { output: [], stopReason: 'error' }) + if (fault === 'duplicate-end') recordExternalEnd(parent, childId, { output: [], stopReason: 'error' }) + } + expect(() => ctx.sessionProjections.snapshot(parent, ['subagentExternal'])).toThrow(/external subagent/) + }) +}) diff --git a/packages/subagent/subagent-in-process-driver/tests/fixtures/plugins/preset-tool.js b/packages/subagent/subagent/tests/fixtures/activation-presets/plugins/preset-tool.js similarity index 100% rename from packages/subagent/subagent-in-process-driver/tests/fixtures/plugins/preset-tool.js rename to packages/subagent/subagent/tests/fixtures/activation-presets/plugins/preset-tool.js diff --git a/packages/subagent/subagent-in-process-driver/tests/fixtures/presets/coding/agent.cordis.yml b/packages/subagent/subagent/tests/fixtures/activation-presets/presets/coding/agent.cordis.yml similarity index 100% rename from packages/subagent/subagent-in-process-driver/tests/fixtures/presets/coding/agent.cordis.yml rename to packages/subagent/subagent/tests/fixtures/activation-presets/presets/coding/agent.cordis.yml diff --git a/packages/subagent/subagent-in-process-driver/tests/fixtures/presets/reviewing/agent.cordis.yml b/packages/subagent/subagent/tests/fixtures/activation-presets/presets/reviewing/agent.cordis.yml similarity index 100% rename from packages/subagent/subagent-in-process-driver/tests/fixtures/presets/reviewing/agent.cordis.yml rename to packages/subagent/subagent/tests/fixtures/activation-presets/presets/reviewing/agent.cordis.yml diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 2794f825bd..24d42b8056 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -99,7 +99,8 @@ async function startChild( parent: Agent, label: string, ): Promise { - const started = await ctx.subagents.startContinuable({ + const started = await ctx.subagents.startActivation({ + delivery: 'parent', provider: 'spawn', label, request: { prompt: [{ type: 'text', text: `task: ${label}` }], parent }, @@ -294,15 +295,10 @@ describe('SubagentRuntime.listChildren', () => { }) it('lists one-shot and continuable children under the same parent', async () => { - const { ctx, parent } = await setup([textResponse('once'), textResponse('again')]) - const oneShot = await ctx.subagents.start('spawn', { - prompt: [{ type: 'text', text: 'finish once' }], - parent, - signal: new AbortController().signal, - }) - const oneShotId = oneShot.id - await oneShot.result - await oneShot.dispose() + const { ctx, parent } = await setup([textResponse('again')]) + const oneShotId = await authorChild(ctx, '00000000-0000-4000-8000-00000000ccc1', { + parentSession: parent.id, origin: 'subagent', createdAt: 1, + }, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'one-shot', provider: 'spawn' })) const continuableId = await startChild(ctx, parent, 'continuable child') const entries = await ctx.subagents.listChildren(parent.id) @@ -1232,7 +1228,7 @@ describe('SubagentRuntime.listDescendants', () => { }) it('discovers continuable descendants below ordinary and one-shot intermediates', { timeout: 20_000 }, async () => { - const { ctx, parent } = await setup([textResponse('one shot')]) + const { ctx, parent } = await setup([]) // An ordinary fork has no descriptor: omitted itself, subtree still walked. const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork')) await ctx.sessions.flush(fork) @@ -1241,17 +1237,9 @@ describe('SubagentRuntime.listDescendants', () => { createdAt: 2, origin: 'subagent', }, childEvents(descriptorPayload('under the fork'))) - // A real one-shot child, then a continuable authored below it. - const oneShot = await ctx.subagents.start('spawn', { - label: 'one-shot intermediate', - prompt: [{ type: 'text', text: 'one-shot task' }], - parent, - signal: testSignal, - }) - await oneShot.result - await ctx.sessions.flush(oneShot.localAgent!.session) - const oneShotId = oneShot.id - await oneShot.dispose() + const oneShotId = await authorChild(ctx, '00000000-0000-4000-8000-00000000ccc2', { + parentSession: parent.id, origin: 'subagent', createdAt: 3, + }, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'one-shot', provider: 'spawn', label: 'one-shot intermediate' })) const underOneShot = await authorChild(ctx, '00000000-0000-4000-8000-00000000bbb2', { parentSession: oneShotId, createdAt: 9_999_999_999_999, diff --git a/packages/subagent/subagent/tests/local-activation.ts b/packages/subagent/subagent/tests/local-activation.ts new file mode 100644 index 0000000000..8bb5a5c986 --- /dev/null +++ b/packages/subagent/subagent/tests/local-activation.ts @@ -0,0 +1,57 @@ +/** Local backend fixtures over the production activation lifecycle. */ +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import { type ContinuableCreateSpec, type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { onTestFinished } from 'vitest' + +/** + * Mount isolated persistence and remove it after the context reaches quiescence. + * @param ctx - test runtime that owns the local child sessions. + */ +export async function mountLocalActivations(ctx: Context): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-local-activation-')) + onTestFinished(async () => { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + }) + await ctx.plugin(JsonlSessionPersistence, { root }) +} + +/** + * Start a real activation while retaining its published local Agent for assertions. + * @param ctx - runtime carrying the subagent and Agent services. + * @param provider - registered local provider name. + * @param request - child task and optional publication cancellation signal. + * @returns The activation with test aliases for its child identity and Agent. + */ +export async function startTestActivation( + ctx: Context, + provider: string, + request: Omit & { signal?: AbortSignal }, +) { + const { signal = new AbortController().signal, label = provider, ...task } = request + const activation = await ctx.get('subagents')!.startActivation({ provider, label, request: task, signal, delivery: 'caller' }) + return { ...activation, id: activation.childId, localAgent: ctx.get('agents')!.get(activation.childId)! } +} + +/** + * Supply a test-selected seed through a temporary preparation provider. + * @param request - child task and exact live parent. + * @param prepared - detached local creation inputs. + * @returns The published local activation and assertion aliases. + */ +export function startPreparedActivation(request: SubagentStartRequest, prepared: ContinuableCreateSpec = {}) { + const ctx = request.parent.ctx + const provider = `prepared-${randomUUID()}` + const unregister = ctx.get('subagents')!.registerProvider({ + name: provider, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: prepared.seed !== undefined, + prepareContinuable: () => Promise.resolve(prepared), + }) + return startTestActivation(ctx, provider, request).finally(() => { unregister() }) +} diff --git a/packages/subagent/subagent/tests/run-settlement.spec.ts b/packages/subagent/subagent/tests/run-settlement.spec.ts index 146181d43a..fb7a9aaad3 100644 --- a/packages/subagent/subagent/tests/run-settlement.spec.ts +++ b/packages/subagent/subagent/tests/run-settlement.spec.ts @@ -1,105 +1,11 @@ import { describe, expect, it } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session' import { - settleRun, settleRunResult, } from '../src/index.ts' const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096 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 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', - }) - }) - - it('keeps provider diagnostics separate in failed background outcomes', async () => { - await expect(settleRun({ - id: SessionId('child-diagnostic'), - localAgent: undefined, - result: Promise.resolve({ - output: [{ type: 'text', text: 'partial assistant text' }], - diagnostic: 'Claude Code denied a tool request', - stopReason: 'error', - }), - dispose: () => Promise.resolve(), - })).resolves.toEqual({ - status: 'failed', - detail: 'error; diagnostic: Claude Code denied a tool request', - }) - }) - - it('treats a diagnostic-bearing remote abort as failed without changing local cancellation', async () => { - await expect(settleRun({ - id: SessionId('child-remote-abort'), - localAgent: undefined, - result: Promise.resolve({ - output: [], - diagnostic: 'ACP permission was denied', - stopReason: 'aborted', - }), - dispose: () => Promise.resolve(), - })).resolves.toEqual({ - status: 'failed', - detail: 'aborted; diagnostic: ACP permission was denied', - }) - }) - it('bounds multibyte diagnostics and marks truncation', async () => { const exact = 'x'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES) const oversized = '权限'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index f40fc11dff..b027063d6e 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -1,6 +1,8 @@ import { describe, expect, expectTypeOf, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/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 { HarnessError, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { carrierKeyOf } from '@deepseek-ai/dsh-scope' @@ -12,6 +14,8 @@ import SubagentRuntime, { assertSubagentMaxDepth, type ResolvedSubagentStartRequest, type SubagentCapabilities, + type SubagentActivation, + type SubagentActivationSpec, type SubagentProvider, type SubagentResult, type SubagentRun, @@ -63,16 +67,42 @@ class StubProvider implements SubagentProvider { } } -async function service(): Promise<{ ctx: Context; subagents: SubagentRuntime }> { +const contexts = new WeakMap() + +async function service(withAgents = true): Promise<{ ctx: Context; subagents: SubagentRuntime }> { const ctx = new Context() // The registry is a required injection of SubagentRuntime (its projection // units register in the constructor). - await ctx.plugin(SessionProjectionRegistry) + if (withAgents) { + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + } else { + await ctx.plugin(SessionProjectionRegistry) + } await ctx.plugin(SubagentRuntime) - return { ctx, subagents: ctx.subagents } + const subagents = ctx.subagents + contexts.set(subagents, ctx) + onTestFinished(() => ctx.fiber.dispose()) + return { ctx, subagents } +} + +/** Start through the managed API with a real parent owned by the fixture. */ +async function start(subagents: SubagentRuntime, provider: string, request: SubagentStartRequest): Promise { + const ctx = contexts.get(subagents)! + const parent = ctx.agents.get(request.parent.id) + ?? (await ctx.agents.create({ sessionId: request.parent.id })).agent + const { label, signal, ...options } = request + return subagents.startActivation({ + provider, label: label ?? provider, request: { ...options, parent }, signal, delivery: 'caller', + }) } describe('SubagentRuntime', () => { + it('has no owned child work before the Agent service is mounted', async () => { + const { subagents } = await service(false) + await expect(subagents.waitForChildren(fakeParent())).resolves.toBe(false) + }) + it('releases its catalog projection binding with the service fiber', async () => { const ctx = new Context() await ctx.plugin(SessionProjectionRegistry) @@ -102,7 +132,7 @@ describe('SubagentRuntime', () => { const dispose = subagents.registerProvider(provider) expect(subagents.list()).toEqual(['alpha']) expect(subagents.getProvider('alpha')).toBe(provider) - const run = await subagents.start('alpha', baseRequest()) + const run = await start(subagents, 'alpha', baseRequest()) await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) expect(provider.startCount).toBe(1) @@ -124,7 +154,7 @@ describe('SubagentRuntime', () => { subagents.registerProvider(new StubProvider('dup')) expect(() => { subagents.registerProvider(new StubProvider('dup')) }) .toThrow(expect.objectContaining({ code: 'DUPLICATE_PROVIDER' })) - await expect(subagents.start('missing', baseRequest())) + await expect(start(subagents, 'missing', baseRequest())) .rejects.toMatchObject({ code: 'NO_PROVIDER' }) }) @@ -133,32 +163,36 @@ describe('SubagentRuntime', () => { const provider = new StubProvider('one-shot') subagents.registerProvider(provider) const request = baseRequest() - await subagents.start('one-shot', request) + await start(subagents, 'one-shot', request) expect(provider.lastRequest).toEqual({ ...request, + label: 'one-shot', + parent: provider.lastRequest!.parent, + signal: provider.lastRequest!.signal, descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'one-shot', provider: 'one-shot', + label: 'one-shot', }, }) expect(provider.lastRequest).not.toBe(request) - expectTypeOf[1]>().toExtend() + expectTypeOf[0]>().toEqualTypeOf() expect('resume' in subagents).toBe(false) expect('resume' in provider).toBe(false) }) it('does not expose manager teardown and treats public drains as no-ops when no manager was bound', async () => { - const { subagents } = await service() + const { subagents } = await service(false) // Without `ctx.agents` no manager exists, so nothing was ever materialized. expect('drainContinuable' in subagents).toBe(false) - await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined() - await expect(subagents.drainContinuableChildren(fakeParent(), [SessionId('child')])).resolves.toBeUndefined() + await expect(subagents.drainDescendants([])).resolves.toBeUndefined() + await expect(subagents.drainChildren(fakeParent(), [SessionId('child')])).resolves.toBeUndefined() }) it('treats interrupt as an accepted no-op when no manager was bound', async () => { - const { subagents } = await service() + const { subagents } = await service(false) // Without a continuation manager no live Activation can exist, so there is // nothing to stop and nothing to authorize against. expect(() => { subagents.interrupt(SessionId('child'), { @@ -168,13 +202,14 @@ describe('SubagentRuntime', () => { }) it('rejects continuable operations when their runtime services are absent', async () => { - const { subagents } = await service() - await expect(subagents.startContinuable({ + const { subagents } = await service(false) + await expect(subagents.startActivation({ + delivery: 'parent', provider: 'unused', label: 'unused child', request: baseRequest(), signal: new AbortController().signal, - })).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) + })).rejects.toMatchObject({ code: 'NO_PROVIDER' }) await expect(subagents.sendMessage( fakeParent(), SessionId('child'), @@ -193,18 +228,35 @@ describe('SubagentRuntime', () => { const { subagents } = await service() const provider = new StubProvider('weak', NO_CAPS) subagents.registerProvider(provider) - await expect(subagents.start('weak', baseRequest(override))) + await expect(start(subagents, 'weak', baseRequest(override))) .rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) expect(provider.startCount).toBe(0) }) + it('rejects a reserved child identity for an external backend before startup', async () => { + const { ctx, subagents } = await service() + const provider = new StubProvider('external') + subagents.registerProvider(provider) + const { agent: parent } = await ctx.agents.create({ sessionId: SessionId('reserved-parent') }) + await expect(subagents.startActivation({ + provider: provider.name, + label: 'Reserved team child', + childId: SessionId('reserved-child'), + request: { parent, prompt: [{ type: 'text', text: 'Work' }] }, + signal: new AbortController().signal, + delivery: 'caller', + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) + expect(provider.startCount).toBe(0) + expect(ctx.agents.get(SessionId('reserved-child'))).toBeUndefined() + }) + it('validates depth and schema semantics before provider startup', async () => { const { subagents } = await service() const provider = new StubProvider('strong') subagents.registerProvider(provider) - await expect(subagents.start('strong', baseRequest({ maxDepth: -1 }))) + await expect(start(subagents, 'strong', baseRequest({ maxDepth: -1 }))) .rejects.toThrow('non-negative safe integer') - await expect(subagents.start('strong', baseRequest({ outputSchema: { type: 'string' } as never }))) + await expect(start(subagents, 'strong', baseRequest({ outputSchema: { type: 'string' } as never }))) .rejects.toThrow() expect(provider.startCount).toBe(0) expect(() => { assertSubagentMaxDepth(undefined) }).not.toThrow() @@ -220,14 +272,14 @@ describe('SubagentRuntime', () => { inheritsParentContext: false, start: () => ready.promise, }) - const parent = fakeParent('delegator') + const parent = (await ctx.agents.create({ sessionId: SessionId('delegator') })).agent const events: string[] = [] const keys: unknown[] = [] const runIds: string[] = [] ctx.on('subagent/start', function (info) { events.push('start'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) }) ctx.on('subagent/end', function (info) { events.push('end'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) }) - const starting = subagents.start('deferred', baseRequest({ parent })) + const starting = start(subagents, 'deferred', baseRequest({ parent })) await Promise.resolve() expect(events).toEqual([]) ready.resolve({ id: SessionId('child'), localAgent: undefined, result: result.promise, async dispose() {} }) @@ -235,7 +287,7 @@ describe('SubagentRuntime', () => { expect(events).toEqual(['start']) result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' }) await run.result - await Promise.resolve() + await run.dispose() expect(events).toEqual(['start', 'end']) expect(keys).toEqual([parent, parent]) expect(runIds[0]).toBe(runIds[1]) @@ -247,8 +299,10 @@ describe('SubagentRuntime', () => { const runIds: string[] = [] ctx.on('subagent/start', info => void runIds.push(info.runId)) - const first = await subagents.start('reused', baseRequest()) - const second = await subagents.start('reused', baseRequest()) + const first = await start(subagents, 'reused', baseRequest()) + await first.result + await first.dispose() + const second = await start(subagents, 'reused', baseRequest()) await Promise.all([first.result, second.result]) expect(runIds).toHaveLength(2) @@ -266,17 +320,15 @@ describe('SubagentRuntime', () => { const lifecycle = vi.fn() ctx.on('subagent/start', lifecycle) ctx.on('subagent/end', lifecycle) - await expect(subagents.start('failed', baseRequest())).rejects.toThrow('setup rolled back') + await expect(start(subagents, 'failed', baseRequest())).rejects.toThrow('setup rolled back') expect(lifecycle).not.toHaveBeenCalled() }) - it.each([false, true])('handles a rejected local result after catalog failure (disposal fails: %s)', async (failsDisposal) => { + it.each([false, true])('handles a rejected external result after catalog failure (disposal fails: %s)', async (failsDisposal) => { const { ctx, subagents } = await service() - onTestFinished(() => ctx.fiber.dispose()) - const parentSession = Session.create(SessionId('catalog-parent')) - const childSession = Session.create(SessionId('catalog-child')) - const parent = { id: parentSession.id, session: parentSession } as Agent - const localAgent = { id: childSession.id, session: childSession } as Agent + const parent = (await ctx.agents.create({ sessionId: SessionId('catalog-parent') })).agent + const parentSession = parent.session + const childId = SessionId('catalog-child') const result = Promise.withResolvers() const cleanupFailure = new Error('dispose also failed') const warnings = vi.spyOn(ctx.logger, 'warn') @@ -291,8 +343,8 @@ describe('SubagentRuntime', () => { capabilities: NO_CAPS, inheritsParentContext: false, start: () => Promise.resolve({ - id: childSession.id, - localAgent, + id: childId, + localAgent: undefined, result: result.promise, dispose, }), @@ -302,11 +354,13 @@ describe('SubagentRuntime', () => { throw catalogFailure }) - await expect(subagents.start('catalog-failure', baseRequest({ parent }))) + await expect(start(subagents, 'catalog-failure', baseRequest({ parent }))) .rejects.toBe(catalogFailure) expect(append).toHaveBeenCalledOnce() expect(dispose).toHaveBeenCalledOnce() - expect(warnings).toHaveBeenCalledTimes(failsDisposal ? 1 : 0) + expect(warnings).toHaveBeenCalledOnce() + expect(warnings.mock.calls[0]![0]).toContain('run infrastructure failed') + if (failsDisposal) expect(warnings.mock.calls[0]![0]).toContain('dispose also failed') }) it('emits an enriched end event and maps result rejection to error telemetry', async () => { @@ -318,9 +372,9 @@ describe('SubagentRuntime', () => { subagents.registerProvider(completed) const ended = vi.fn() ctx.on('subagent/end', ended) - const run = await subagents.start('completed', baseRequest()) + const run = await start(subagents, 'completed', baseRequest()) await run.result - await Promise.resolve() + await run.dispose() expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'completed', lastAssistantMessage: [{ type: 'text', text: 'answer' }], @@ -331,9 +385,9 @@ describe('SubagentRuntime', () => { // matching the continuable epoch event. const silent = new StubProvider('silent', NO_CAPS, { output: [], stopReason: 'completed' }) subagents.registerProvider(silent) - const silentRun = await subagents.start('silent', baseRequest()) + const silentRun = await start(subagents, 'silent', baseRequest()) await silentRun.result - await Promise.resolve() + await silentRun.dispose() const silentEnd = ended.mock.calls.map(call => call[0] as SubagentRunEndInfo).find(info => info.provider === 'silent') expect(silentEnd).toBeDefined() expect('lastAssistantMessage' in silentEnd!).toBe(false) @@ -347,10 +401,10 @@ describe('SubagentRuntime', () => { return { id: SessionId('infra-child'), localAgent: undefined, result: failure.promise, async dispose() {} } }, }) - const failedRun = await subagents.start('infra', baseRequest()) + const failedRun = await start(subagents, 'infra', baseRequest()) failure.reject(new Error('transport')) await expect(failedRun.result).rejects.toThrow('transport') - await Promise.resolve() + await expect(failedRun.dispose()).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'infra', stopReason: 'error' })) }) diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent/tests/structured.spec.ts similarity index 80% rename from packages/subagent/subagent-in-process-driver/tests/structured.spec.ts rename to packages/subagent/subagent/tests/structured.spec.ts index 1ec0b16852..8b865e741b 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent/tests/structured.spec.ts @@ -10,13 +10,14 @@ 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 SubagentRuntime, { - type ResolvedSubagentStartRequest, type SubagentStartRequest, + type SubagentActivation, } from '@deepseek-ai/dsh-subagent' import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools' import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { startInProcessRun } from '../src/index.ts' +import { TestSessionQuery } from './test-session-query.ts' +import { mountLocalActivations, startTestActivation } from './local-activation.ts' import { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL, @@ -49,9 +50,7 @@ const SCHEMA: ObjectJsonSchema = { } /** - * Real loop, scripted model, and inline fresh-conversation provider over the shared driver. Loading - * spawn/fork here would create a dev-dependency cycle; their specs cover plugin integration while - * this fixture isolates driver behavior and scripts the child's `structured_output` calls. + * Real loop and local preparation isolate activation-scoped structured output. */ async function setup(script: Script, options: SetupOptions = {}) { const ctx = new Context() @@ -68,13 +67,14 @@ async function setup(script: Script, options: SetupOptions = {}) { } as never) } await mountInvariants(ctx) + await mountLocalActivations(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) const disposeProvider = ctx.subagents.registerProvider({ name: 'spawn', capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: false, persona: false }, inheritsParentContext: false, - start: (request: ResolvedSubagentStartRequest) => startInProcessRun(request, {}), + prepareContinuable: () => Promise.resolve({}), }) ctx.llm.registerAdapter(['mock'], adapter) const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) @@ -113,7 +113,7 @@ describe('in-process structured output', () => { ctx.on('tools/result', (exec, toolResult) => { if (exec.name === STRUCTURED_OUTPUT_TOOL && !toolResult.isError) acknowledgement = toolResult.value }) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('completed') expect(result.structured).toEqual({ answer: 42, note: 'done' }) @@ -126,7 +126,7 @@ describe('in-process structured output', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), textResponse('MUST NOT BE CONSUMED'), ]) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) await run.result // The structured tool marks its successful result as turn-concluding. expect(adapter.requests.length).toBe(1) @@ -156,7 +156,7 @@ describe('in-process structured output', () => { return Promise.resolve([{ type: 'text', text: 'ran' }]) }, })) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('completed') expect(result.structured).toEqual({ answer: 5 }) @@ -184,7 +184,7 @@ describe('in-process structured output', () => { return Promise.resolve([{ type: 'text', text: 'ran' }]) }, })) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) // Registered after the child and prepended: this listener returns allow // after every downstream pre-execute decision. The service-owned guard // runs after the waterfall and can only deny, so the body still cannot run. @@ -221,7 +221,7 @@ describe('in-process structured output', () => { return Promise.resolve([{ type: 'text', text: 'ran' }]) }, })) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) const result = await run.result // The call ran BEFORE captured was set: the deny gate only guards the // window after the terminal answer landed. @@ -235,12 +235,12 @@ describe('in-process structured output', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }), toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), ]) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) const result = await run.result expect(result.structured).toEqual({ answer: 7 }) expect(result.stopReason).toBe('completed') // The child's log carries the isError tool/result for the invalid call. - const child = ctx.agents.get(run.id)! + const child = run.localAgent const results = child.session.snapshotEvents().filter(e => e.type === 'tool/result') expect(results.length).toBe(2) expect(results[0]!.data.message.content[0].isError).toBe(true) @@ -252,13 +252,13 @@ describe('in-process structured output', () => { textResponse('here is my answer in prose'), textResponse('MUST NOT BE CONSUMED'), ]) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('error') expect(result.structured).toBeUndefined() // Exactly one model request and one caller-supplied user message: no nudge turn exists. expect(adapter.requests.length).toBe(1) - const child = ctx.agents.get(run.id)! + const child = run.localAgent expect(child.session.snapshotEvents().filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) await run.dispose() }) @@ -266,31 +266,113 @@ describe('in-process structured output', () => { it('an errored child keeps its honest error result (no capture expected)', async () => { // Script exhaustion on the first call → the child turn errors. const { ctx, parent, adapter } = await setup([]) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('error') expect(adapter.requests.length).toBe(1) await run.dispose() }) - it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => { - const { ctx, parent } = await setup([textResponse('prose, no capture')]) - const controller = new AbortController() - const run = await ctx.subagents.start('spawn', structuredRequest(parent, { signal: controller.signal })) - // Cancel synchronously inside the turn's end recording: the cancel - // contract outranks the schema shortfall, so the result maps to aborted. - ctx.on('session/event', (session, event) => { - const child = ctx.agents.get(run.id) - if (session === child?.session && event.type === 'turn/end') controller.abort('cancelled at turn end') - }) - const result = await run.result - expect(result.stopReason).toBe('aborted') + it('disposing a running structured child settles aborted without requiring a capture', async () => { + const { ctx, parent } = await setup(['hang']) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) await run.dispose() + expect((await run.result).stopReason).toBe('aborted') + }) + + it('keeps descendant messages and completion notices before accepting the final structured result', async () => { + const { ctx, parent } = await setup([ + toolCallResponse('spawn-child', 'start_descendant', {}), + toolCallResponse('too-early', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), + toolCallResponse('finish-child', 'finish_descendant', {}), + toolCallResponse('final-capture', STRUCTURED_OUTPUT_TOOL, { answer: 42 }), + ]) + ctx.llm.registerAdapter(['descendant'], new MockAdapter(['hang'])) + let descendant: SubagentActivation | undefined + ctx.tools.register(defineContentToolFixture({ + name: 'start_descendant', description: 'Start delegated work', parameters: {}, + async execute(_args, exec): Promise { + if (exec.agent === undefined) throw new Error('expected the structured parent') + descendant = await ctx.subagents.startActivation({ + provider: 'spawn', label: 'Nested work', signal: testToolSignal, delivery: 'parent', + request: { + parent: exec.agent, prompt: [{ type: 'text', text: 'Work until released' }], + agentOptions: { provider: 'descendant', model: 'mock' }, + }, + }) + return [{ type: 'text', text: 'Child accepted' }] + }, + })) + ctx.tools.register(defineContentToolFixture({ + name: 'finish_descendant', description: 'Return delegated work', parameters: {}, + async execute(_args, exec): Promise { + if (descendant === undefined || exec.agent === undefined) throw new Error('expected resident child and parent') + const sender = ctx.agents.get(descendant.childId) + if (sender === undefined) throw new Error('expected a resident descendant') + await ctx.subagents.sendMessage(sender, exec.agent.id, [{ type: 'text', text: 'Descendant result reached parent' }], { + signal: testToolSignal, + }) + await descendant.dispose() + return [{ type: 'text', text: 'Child released' }] + }, + })) + const activation = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) + try { + await expect(activation.result).resolves.toMatchObject({ stopReason: 'completed', structured: { answer: 42 } }) + const events = activation.localAgent.session.snapshotEvents() + const captures = events.filter(event => event.type === 'tool/result') + .flatMap(event => event.data.message.content) + .filter(block => block.toolCallId === 'too-early' || block.toolCallId === 'final-capture') + expect(captures).toHaveLength(2) + expect(captures[0]?.isError).toBe(true) + expect(JSON.stringify(captures[0]?.content)).toContain('Wait for all delegated child tasks to finish') + expect(captures[1]?.isError).not.toBe(true) + const messages = events.filter(event => event.type === 'user/message') + expect(JSON.stringify(messages)).toContain('Descendant result reached parent') + expect(messages.some(event => event.data.source.kind === 'subagent-settled')).toBe(true) + } finally { + await descendant?.dispose() + await activation.dispose() + } + }) + + it('rejects new input after capture until closure, then cold-resumes without the schema', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('capture', STRUCTURED_OUTPUT_TOOL, { answer: 42 }), + textResponse('ordinary resumed answer'), + ]) + await ctx.plugin(TestSessionQuery) + const flushing = Promise.withResolvers() + const releaseFlush = Promise.withResolvers() + let held = false + ctx.on('session/flush', async (session) => { + if (session.header.parentSession === undefined || held) return + held = true + flushing.resolve(undefined) + await releaseFlush.promise + }) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) + try { + await flushing.promise + await expect(ctx.subagents.sendMessage(parent, run.childId, [{ type: 'text', text: 'too early' }], { + signal: testToolSignal, + })).rejects.toMatchObject({ code: 'INPUT_CLOSED' }) + } finally { + releaseFlush.resolve(undefined) + await run.dispose() + } + expect((await run.result).structured).toEqual({ answer: 42 }) + await ctx.subagents.sendMessage(parent, run.childId, [{ type: 'text', text: 'continue normally' }], { + signal: testToolSignal, + }) + await ctx.subagents.waitForChildren(parent) + expect(adapter.requests).toHaveLength(2) + expect(toolNames(adapter.requests[1]!)).not.toContain(STRUCTURED_OUTPUT_TOOL) }) it('rejects a schema outside the subset loud, before any child exists', async () => { const { ctx, parent } = await setup([]) - await expect(ctx.subagents.start('spawn', structuredRequest(parent, { + await expect(startTestActivation(ctx, 'spawn', structuredRequest(parent, { outputSchema: { type: 'object', oneOf: [] } as unknown as ObjectJsonSchema, }))).rejects.toThrow(/unsupported JSON schema/) expect(ctx.agents.get(SessionId('parent'))).toBeDefined() @@ -299,7 +381,7 @@ describe('in-process structured output', () => { it('a schema carrying non-JSON values fails as JsonSchemaError at the validation boundary', async () => { const { ctx, parent } = await setup([]) // Semantic assertion runs before provider startup. - await expect(ctx.subagents.start('spawn', structuredRequest(parent, { + await expect(startTestActivation(ctx, 'spawn', structuredRequest(parent, { outputSchema: { type: 'object', default: () => {} } as unknown as ObjectJsonSchema, }))).rejects.toThrow(/unsupported JSON schema.*annotation must be lossless JSON data/) }) @@ -317,13 +399,13 @@ describe('in-process structured output', () => { } return next() }) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) const result = await run.result // No capture was committed: the run reports the schema shortfall... expect(result.structured).toBeUndefined() expect(result.stopReason).toBe('error') // ...the logged tool result is the blocked isError with the feedback... - const child = ctx.agents.get(run.id)! + const child = run.localAgent const results = child.session.snapshotEvents().filter(e => e.type === 'tool/result') expect(results[0]!.data.message.content[0].isError).toBe(true) expect(JSON.stringify(results[0]!.data.message.content)).toContain('capture rejected by hook') @@ -343,7 +425,7 @@ describe('in-process structured output', () => { } return next() }) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('completed') expect(result.structured).toEqual({ answer: 8 }) @@ -355,7 +437,7 @@ describe('in-process structured output', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }), textResponse('capture was rejected'), ]) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) // Registered after attachment and prepended, so it wraps every listener // the child installed. It delegates first, then converts the apparent // capture success into the pipeline's authoritative failure. @@ -382,7 +464,7 @@ describe('in-process structured output', () => { // replace them (AgentOptions has no prompt field — the instruction is an // ordinary child-scoped prompt registration). ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' }) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) await run.result const childSystem = requestSystem(adapter.requests.at(-1)!) expect(childSystem).toContain('You are a counter.') @@ -403,7 +485,7 @@ describe('in-process structured output', () => { return { logs: [], value: 'captured' } }, }) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) const result = await run.result expect(result.structured).toEqual({ answer: 12 }) @@ -434,13 +516,13 @@ describe('in-process structured output', () => { } as never }, }) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) const result = await run.result expect(result.structured).toBeUndefined() expect(result.stopReason).toBe('error') expect(adapter.requests).toHaveLength(2) - const child = ctx.agents.get(run.id)! + const child = run.localAgent const outer = child.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.source.callId === ToolCallId('c1')) expect(outer?.type === 'tool/result' && outer.data.message.content[0].isError).toBe(true) @@ -463,7 +545,7 @@ describe('in-process structured output', () => { ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME ? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] }) : next()) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) const result = await run.result expect(result.structured).toBeUndefined() @@ -480,7 +562,7 @@ describe('in-process structured output', () => { parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })) await parent.whenIdle() expect(requestSystem(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_INSTRUCTION) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) await run.result // The loop always assembles a base prompt (the harness identity section), // so the instruction APPENDS — never replaces. @@ -511,7 +593,7 @@ describe('in-process structured output', () => { await parent.whenIdle() expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) await run.result const childRequest = adapter.requests[1]! expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL) @@ -544,8 +626,8 @@ describe('in-process structured output', () => { return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args) }, ]) - const runA = await ctx.subagents.start('spawn', structuredRequest(parent)) - const runB = await ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema })) + const runA = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) + const runB = await startTestActivation(ctx, 'spawn', structuredRequest(parent, { outputSchema: otherSchema })) const [a, b] = await Promise.all([runA.result, runB.result]) expect(a.structured).toEqual({ answer: 1 }) expect(b.structured).toEqual({ verdict: 'real' }) @@ -574,7 +656,7 @@ describe('in-process structured output', () => { order: ctx.systemPrompt.getSectionOrder('STRUCTURED_OUTPUT') + 10, text: 'AFTER-BAND', }) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) await run.result const request = adapter.requests[0]! const names = toolNames(request) @@ -601,13 +683,13 @@ describe('in-process structured output', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }), ]) expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) // A backend hot-reload mid-run must not unregister the capture tool out // from under the live child: the registration rides the CHILD's fiber. disposeProvider() const result = await run.result expect(result.structured).toEqual({ answer: 4 }) - const child = ctx.agents.get(run.id)! + const child = run.localAgent expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeDefined() await run.dispose() // Child disposed ⇒ its scoped registrations are gone. @@ -644,7 +726,7 @@ describe('in-process structured output', () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) // A prepended post-execute listener blocks the first capture without // delegating. The final-result notification discards that execution's // stage when it observes the error. @@ -657,7 +739,7 @@ describe('in-process structured output', () => { return next() }, { prepend: true }) const result = await run.result - const child = ctx.agents.get(run.id)! + const child = run.localAgent // The blocked capture must NOT surface as structured success… expect(result.stopReason).toBe('error') expect(result.structured).toBeUndefined() @@ -687,7 +769,7 @@ describe('in-process structured output', () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) // Block the first capture after its body stages a value. Its final error // discards that execution's stage. let blocks = 1 @@ -699,7 +781,7 @@ describe('in-process structured output', () => { return next() }, { prepend: true }) await run.result - const child = ctx.agents.get(run.id)! + const child = run.localAgent // A SECOND capture call with the SAME call id whose body never stages // (invalid args throw before the stage): the discarded value must not ride // its acceptance. @@ -727,7 +809,7 @@ describe('in-process structured output', () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) // Discard the first capture's stage via a final post-execute block. let blocks = 1 ctx.on('tools/post-execute', (exec, _result, next) => { @@ -738,7 +820,7 @@ describe('in-process structured output', () => { return next() }, { prepend: true }) await run.result - const child = ctx.agents.get(run.id)! + const child = run.localAgent // A prepended pre-execute deny skips the body, while the denied call still // reaches the final notification with the same adapter-minted call id. const offDeny = ctx.on('tools/pre-execute', (exec) => { diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 8648737494..d257154abe 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -59,9 +59,6 @@ { "path": "../../session-query/session-query" }, - { - "path": "../../jobs/jobs" - }, { "path": "../../typert/protocol" }, diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index d4f18d593e..11af016aab 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: 99ca52dd4a7ea2629441c4d661632b8598c8171d -README.zh.md: 1ff49a3179dbe61db1a9353e2fab131082cb29ae +README.md: 09cf539d90477b1cda62a70d7e70749f7c02728c +README.zh.md: 452f2d13cb36717c9143fa2865a356fd851be4b4 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 99ca52dd4a..09cf539d90 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-tool-subagent-control` adds the global control tools for continuable children: `send_message` steers between a direct parent and child, `interrupt_agent` stops a child's current turn while keeping its inbox and descendants intact, and `list_agents` (from the separately loadable `list-agents` plugin) lists continuable children by durable id and label. Parents and continuable children inherit the same `send_message` definition and ordering, so model communication adds no child-only tool schema. No tool's presence decides whether a delegation tool starts continuable work. +`dsh-tool-subagent-control` adds the global control tools for continuable children: `send_message` steers between a direct parent and child, `interrupt_agent` stops a child's current turn while keeping its inbox and descendants intact, and `list_agents` (from the separately loadable `list-agents` plugin) lists local children and external executions by durable id and label. Parents and continuable children inherit the same `send_message` definition and ordering, so model communication adds no child-only tool schema. No tool's presence decides whether a delegation tool starts continuable work. ## Table of Contents @@ -37,7 +37,6 @@ Load the subagent service, a backend, the delegation tool, and this package. Add - name: '@deepseek-ai/dsh-tool-subagent' config: provider: spawn - backgroundMode: continuable - name: '@deepseek-ai/dsh-tool-subagent-control' - name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' ``` @@ -54,7 +53,7 @@ Stops only the target's current turn: queued messages stay parked until a later ### list_agents -Lists the continuable children below the calling agent: `children` (default) shows direct children, `descendants` walks the whole tree in stable pre-order, annotating each entry with its durable direct-parent session id and depth. Status comes from the live Agent registry — `running`, `idle`, or `ready`. One-shot children are intentionally absent because they cannot accept `send_message`, and unreadable candidates appear as diagnostics. +Lists subagents below the calling agent: `children` (default) shows direct children, `descendants` walks the whole tree in stable pre-order, annotating each entry with its durable direct-parent session id and depth. Status comes from the live Agent registry — `running`, `idle`, or `ready`. External executions show `running` or `finished` and `continuable: false`; they cannot receive follow-ups. Historical one-shot Sessions are omitted, and unreadable candidates appear as diagnostics. ----- @@ -76,7 +75,7 @@ The tool forwards its execution signal, which owns admission only until inbox ac ### Listing projection -`list_agents` derives the root id from the calling agent, reads the service catalog without a cursor, refines each candidate's status through the live Agent registry, and omits one-shot children because they cannot accept `send_message`. Diagnostics keep their positions in the descendants scope and never expose descriptor contents. +`list_agents` derives the root id from the calling agent, reads the service catalog without a cursor, refines each candidate's status through the live Agent registry, and includes external executions with their activation status. Historical one-shot Sessions are omitted. Diagnostics keep their positions in the descendants scope and never expose descriptor contents. ### Source map @@ -150,11 +149,11 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -One line per continuable child in stable catalog order: ` [] —
@@ -146,7 +146,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -Parent delivery emits a user-role status notice for local children, whose results reach the parent through child-authored `sendMessage()` calls. External notices also include the child’s nonempty final text blocks and state that further messages are unsupported. Reasoning and other nontext blocks are excluded. Caller delivery emits no notice; SDK lifecycle notifications retain the complete child output. +Parent delivery emits a user-role status notice for local children, whose results reach the parent through child-authored `send_message` calls. If that tool is unavailable to the child, the notice includes its final text instead. External notices also include the child’s nonempty final text blocks and state that further messages are unsupported. Reasoning and other nontext blocks are excluded. Caller delivery emits no notice; SDK lifecycle notifications retain the complete child output. #### Token effect diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 6d529af9e1..762d93b881 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" ### 失败与恢复 -不受提供方支持的能力会使创建失败。调用方信号仅取消尚未发布的工作;发布后通过 `dispose()` 取消该次 activation,并等待其后代和资源释放。`result` 携带输出、可选结构化数据与停止原因,基础设施故障可能使其拒绝。结果就绪可以早于子树完全停稳,需要完成清理的调用方还应等待 `dispose()`。 +不受提供方支持的能力会使创建失败。调用方信号仅取消尚未发布的工作;发布后通过 `dispose()` 取消该次 activation,并等待其后代和资源释放。`result` 携带输出、可选结构化数据与停止原因,基础设施故障可能使其拒绝。需要完成资源清理的调用方还应等待 `dispose()`。 ----- @@ -108,16 +108,16 @@ kind: "package-reference" ### Activation 结算 -管理器预留子级身份与容量,创建本地 Agent 或外部执行,并接受初始任务。本地 Agent 活动结束且收件箱为空时,`result` 就绪;最终释放还需等待所拥有的后代,并在最终 Session flush 后重新验证活动状态。关闭准入并释放句柄可防止迟到工作进入已释放的 Agent。父级投递在结算后发送通知;调用方投递由等待中的工作流收集。Headless 宿主交替等待 `agent.whenIdle()` 与 `waitForChildren(agent)`,直至没有子级工作,让完成通知可驱动父级生成最终答案。 +管理器预留子级身份与容量,创建本地 Agent 或外部执行,并接受初始任务。本地 Agent 活动、待处理输入及所拥有的后代均结束后,管理器在最终 Session flush 后重新验证活动状态,使 `result` 就绪。管理器在关闭准入时发布结果,随后释放本地句柄。外部结果可以早于进程清理完成而就绪。关闭准入并释放句柄可防止迟到工作进入已释放的 Agent。父级投递在结算后发送通知;调用方投递由等待中的工作流收集。Headless 宿主交替等待 `agent.whenIdle()` 与 `waitForChildren(agent)`,直至没有子级工作,让完成通知可驱动父级生成最终答案。 -本地与外部创建成功时,都向父 Session 追加 `subagent/catalog` 事实。外部条目没有子 Session,只携带最近记录的结果状态。终态更新替换同一目录条目;pending 不表示进程正在运行。完整结果交给调用方或父级完成通知。投影排除继承事实。直接列表读取一个父级投影;后代列表结合语料中的身份与父级目录,并复用观察结果。 +本地与外部创建成功时,都向父 Session 追加 `subagent/catalog` 事实。外部条目没有子 Session,只携带最近记录的结果状态。终态更新替换同一目录条目;pending 不表示进程正在运行。完整结果交给调用方或父级完成通知。投影排除继承事实。直接列表读取一个父级投影;后代列表通过语料中的身份枚举具有 Session 的子级。 ### 所有权与不变式 - **管理器拥有已接受工作**——未发布的失败会回滚;已发布句柄取消对应 activation,并等待子级优先释放。 - **注册受 effect 作用域约束**——移除提供方会阻止新启动,但绝不撤销已接受的运行。 - **Agent 消息权限基于确切相邻关系**——`sendMessage()` 要求确切在线 sender;每个 sender 都可以指定直接可继续 child,只有具备驻留可继续 Activation 的 sender 可以指定自己的直接 parent。 -- **描述符仅进日志**——它是会话事件,不进入模型历史,并跨压缩(compaction)保留;可继续描述符会显式记录解析后的子级提供方、模型与推理强度,用于冷恢复。 +- **描述符仅进日志**——它是会话事件,不进入模型历史,并跨压缩(compaction)保留;写入器记录解析后的子级提供方、模型与推理强度,用于冷恢复。历史 one-shot 描述符仍可读取。
@@ -146,7 +146,7 @@ kind: "package-reference" #### 模型看到什么 -父级投递为本地子级发送 user-role 状态通知,子级通过自行编写的 `sendMessage()` 调用向父级汇报结果。外部通知还包含子级最终输出中的非空文本块,并说明不支持后续消息;推理及其他非文本块被排除。调用方投递不发送通知;SDK 生命周期通知保留完整子级输出。 +父级投递为本地子级发送 user-role 状态通知,子级通过自行编写的 `send_message` 调用向父级汇报结果。如果子级无法使用该工具,通知会附带其最终文本。外部通知还包含子级最终输出中的非空文本块,并说明不支持后续消息;推理及其他非文本块被排除。调用方投递不发送通知;SDK 生命周期通知保留完整子级输出。 #### Token 影响 diff --git a/packages/subagent/subagent/src/activation-driver.ts b/packages/subagent/subagent/src/activation-driver.ts index 684606a61a..08061e59f5 100644 --- a/packages/subagent/subagent/src/activation-driver.ts +++ b/packages/subagent/subagent/src/activation-driver.ts @@ -12,6 +12,7 @@ import { SubagentError } from './error.ts' import { epochStopReason } from './lifecycle.ts' import type { SubagentPromptRequest } from './control-types.ts' import type { SubagentResult, SubagentRun } from './types.ts' +import type { StructuredAttachment } from './structured.ts' /** Local or external execution; only the local driver accepts further input. */ export type ActivationDriver = LocalActivationDriver | ExternalActivationDriver @@ -65,15 +66,6 @@ interface ActivationLifecycle { dispose(): Promise } -/** Read access to a requested structured result after tool acceptance. */ -export interface ActivationStructuredCapture { - /** - * Read the value committed by the structured-output tool. - * @returns the committed value, or undefined when no value was accepted. - */ - captured(): { value: unknown } | undefined -} - /** Local execution with output restricted to events produced during residency. */ export class LocalActivationDriver implements ActivationLifecycle { /** Execution with a local Agent inbox. */ @@ -88,7 +80,7 @@ export class LocalActivationDriver implements ActivationLifecycle { */ constructor( private readonly handle: AgentHandle, - private readonly structured?: ActivationStructuredCapture, + private readonly structured?: StructuredAttachment, ) { this.agent = handle.agent this.boundary = this.agent.session.seq diff --git a/packages/subagent/subagent/src/continuation-activation.ts b/packages/subagent/subagent/src/continuation-activation.ts index 0db535c5ad..d550734e9b 100644 --- a/packages/subagent/subagent/src/continuation-activation.ts +++ b/packages/subagent/subagent/src/continuation-activation.ts @@ -38,8 +38,9 @@ import type { DelegatedPolicyOverrides } from './child-agent.ts' import { createSettlementMessage } from './continuation-messages.ts' import type { SubagentDescriptorData } from './descriptor.ts' import { SubagentError } from './error.ts' +import { isAdjacentAgentSendMessageTool } from './internal.ts' import type { SubagentPromptRequest } from './control-types.ts' -import type { ActivationObserver, ActivationTerminal } from './lifecycle.ts' +import type { ActivationObserver } from './lifecycle.ts' /** Process-local slots shared through uninterrupted continuable parent links. */ class ActivationPool { @@ -71,15 +72,6 @@ export interface Activation { readonly releaseSlot: () => void /** The durable child this Activation is an epoch of. */ readonly childId: SessionId - /** - * The durable direct parent, stored because settlement delivery must resolve - * that parent after the child handle is gone. {@link ancestry} cannot answer - * it: a `WeakSet` is not enumerable, and the child's own header is only - * reachable through a handle disposal has already released. - */ - readonly parentSession: SessionId - /** The provider name recorded in the durable descriptor. */ - readonly provider: string /** The retained live Agent handle, disposed exactly once at settlement. */ readonly driver: ActivationDriver readonly parent: Agent @@ -352,7 +344,7 @@ export class ContinuableActivationRegistry { const activation = this.resident.get(targetSessionId) if (activation === undefined) return if (authority.kind === 'user') { - if (activation.parentSession !== authority.parentSessionId) { + if (activation.parent.id !== authority.parentSessionId) { throw new SubagentError( `subagent "${targetSessionId}" belongs to another parent session`, 'UNAUTHORIZED', @@ -486,7 +478,7 @@ export class ContinuableActivationRegistry { for (const childId of new Set(childIds)) { const activation = this.resident.get(childId) if (activation === undefined) continue - if (activation.parentSession !== parent.id || !activation.ancestry.has(parent)) { + if (activation.parent.id !== parent.id || !activation.ancestry.has(parent)) { throw new SubagentError( `subagent "${childId}" is not a direct child of agent "${parent.id}"`, 'UNAUTHORIZED', @@ -591,7 +583,7 @@ export class ContinuableActivationRegistry { this.authorizeLineage( parent, activation.childId, - activation.parentSession, + activation.parent.id, ) this.acquireOwnership(parent, activation.childId) try { @@ -605,11 +597,10 @@ export class ContinuableActivationRegistry { /** * Stop and release one Activation through its memoized close transaction. * @param activation - exact residency epoch to close. - * @param finalStateFlushed - whether natural settlement already flushed final state. * @returns the shared close transaction. */ - dispose(activation: Activation, finalStateFlushed = false): Promise { - return this.close(activation, () => this.finishDisposal(activation, finalStateFlushed)) + dispose(activation: Activation): Promise { + return this.close(activation, () => this.finishDisposal(activation)) } /** Close admission synchronously and share one release, including startup rollback. */ @@ -755,8 +746,6 @@ export class ContinuableActivationRegistry { pool, releaseSlot, childId, - parentSession: parent.id, - provider, driver, parent, delivery: inputs.delivery ?? 'parent', @@ -845,7 +834,7 @@ export class ContinuableActivationRegistry { const idleObservation = activation.poke await activation.driver.whenIdle() if (activation.closing !== undefined) return - if (activation.announced && !activation.driver.hasPending) { + if (activation.driver.kind === 'external') { try { activation.result.resolve(activation.driver.capture()) } catch (error: unknown) { @@ -873,9 +862,19 @@ export class ContinuableActivationRegistry { if (activation.driver.version !== finalSeq) { return Promise.resolve('retry') } - // Idle ownership and admission closure must share one synchronous span. + let result: SubagentResult + try { + result = activation.driver.capture() + } catch (error: unknown) { + activation.result.reject(error) + return Promise.resolve({ done: this.dispose(activation) }) + } + // Result publication and admission closure share the final idle claim. let done!: Promise - if (!activation.driver.closeWhenIdle(() => { done = this.dispose(activation, true) })) { + if (!activation.driver.closeWhenIdle(() => { + activation.result.resolve(result) + done = this.close(activation, () => this.finishDisposal(activation, result)) + })) { return Promise.resolve('retry') } return Promise.resolve({ done }) @@ -911,16 +910,18 @@ export class ContinuableActivationRegistry { } /** Propagate stop synchronously, then finish the child-first release. */ - private async finishDisposal(activation: Activation, finalStateFlushed: boolean): Promise { + private async finishDisposal(activation: Activation, settledResult?: SubagentResult): Promise { this.wake(activation) const { childId } = activation const failures: SubagentError[] = [] + let result: SubagentResult = settledResult ?? { output: [], stopReason: 'error' } + const child = activation.driver.agent + const includeOutput = child === undefined + || !isAdjacentAgentSendMessageTool(child.ctx.get('tools')?.get('send_message', child)) const detail = (error: unknown): string => activation.driver.agent === undefined ? failureMessage(error) : errorChain(error) - if (finalStateFlushed) { - activation.observer.captureResult(activation.driver.capture()) - } else { + if (settledResult === undefined) { activation.driver.cancel('parent') const idle = activation.driver.whenIdle() const children = [...activation.ownedChildren] @@ -945,7 +946,7 @@ export class ContinuableActivationRegistry { } await idle await this.flushFinalState(activation) - activation.observer.captureResult(activation.driver.capture()) + result = activation.driver.capture() } catch (error: unknown) { failures.push(new SubagentError( `subagent "${childId}" activation teardown failed: ${detail(error)}`, @@ -977,31 +978,31 @@ export class ContinuableActivationRegistry { } this.resident.delete(childId) activation.releaseSlot() - const terminal = activation.observer.terminal(failure) - const result = { ...terminal, output: terminal.output ?? [] } + if (failure !== undefined) result = { output: [], stopReason: 'error' } activation.result.resolve(result) if (activation.settleCatalog !== undefined) { try { - activation.settleCatalog(terminal.stopReason) + activation.settleCatalog(result.stopReason) } catch (error: unknown) { failure ??= new SubagentError(`subagent "${childId}" catalog settlement could not be recorded`, 'ACTIVATION_TEARDOWN_FAILED', { cause: error }) } } - this.notifySettlement(activation, activation.observer.terminal(failure)) + if (failure !== undefined) result = { output: [], stopReason: 'error' } + this.notifySettlement(activation, result, includeOutput) this.releaseOwnership(childId) - activation.observer.settle(failure) + activation.observer.settle(result) activation.released.resolve() if (failure !== undefined) throw failure } /** Tell the durable direct parent how this Activation ended. */ - private notifySettlement(activation: Activation, terminal: ActivationTerminal): void { + private notifySettlement(activation: Activation, terminal: SubagentResult, includeOutput: boolean): void { if (!activation.announced || activation.delivery === 'caller') return try { - const parent = this.ctx.agents.get(activation.parentSession) + const parent = this.ctx.agents.get(activation.parent.id) if (parent !== activation.parent) return const message = createSettlementMessage( - activation.childId, terminal, activation.driver.agent === undefined, activation.driver.agent !== undefined, + activation.childId, terminal, includeOutput, activation.driver.agent !== undefined, ) if (this.closingTeardownFor(parent) !== undefined) { parent.inject(message) diff --git a/packages/subagent/subagent/src/continuation-messages.ts b/packages/subagent/subagent/src/continuation-messages.ts index 9eb6c35329..f98136e0ab 100644 --- a/packages/subagent/subagent/src/continuation-messages.ts +++ b/packages/subagent/subagent/src/continuation-messages.ts @@ -8,7 +8,6 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { ActivationTerminal } from './lifecycle.ts' import type { SubagentResult } from './types.ts' /** Durable attribution for one model-authored message between adjacent Agents. */ @@ -139,7 +138,7 @@ function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopR */ export function createSettlementMessage( childId: SessionId, - terminal: ActivationTerminal, + terminal: SubagentResult, includeOutput = true, continuable = true, ): ReturnType { @@ -147,7 +146,7 @@ export function createSettlementMessage( // Parent providers receive this notice as a user message and may reject // nontext assistant blocks. Keep this conversion local so SDK/UI consumers // retain the complete child output. - const closingText = (includeOutput ? terminal.output ?? [] : []).flatMap(block => + const closingText = (includeOutput ? terminal.output : []).flatMap(block => block.type === 'text' && block.text.length > 0 ? [block] : [], ) return createUserMessage({ diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index d692ddbd4c..789a2b0c74 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -49,7 +49,7 @@ import type { SubagentInterruptAuthority, SubagentActivationSpec, SubagentActivation, - ResolvedSubagentStartRequest, + SubagentStartRequest, SubagentRun, SubagentSendMessageOptions, } from './types.ts' @@ -69,7 +69,7 @@ type ChildDeliveryOptions = /** Package-private hooks supplied by the owning service. */ interface ContinuationHost { - startExternal(name: string, request: ResolvedSubagentStartRequest): Promise + startExternal(name: string, request: SubagentStartRequest): Promise /** Resolve one provider's detached continuable-creation contribution. */ prepareContinuable(name: string, request: ContinuableCreateRequest): Promise /** Build the lifecycle observer for one Activation residency epoch. */ @@ -207,7 +207,6 @@ export class SubagentContinuationManager { this.activations.assertAdmitting(parent) const pendingId = brandString(randomUUID()) const releaseHold = this.activations.holdOwnership(parent, pendingId) - const descriptor = snapshotSubagentDescriptor({ mode: 'one-shot', provider: spec.provider, label: spec.label }) let activation: Activation | undefined try { activation = await this.activations.materialize({ @@ -219,11 +218,11 @@ export class SubagentContinuationManager { signal: spec.signal, delivery: spec.delivery, external: signal => this.host.startExternal(spec.provider, { - ...spec.request, label: spec.label, descriptor, signal, + ...spec.request, label: spec.label, signal, }), }) this.activations.assertAdmitting(parent) - this.activations.authorizeLineage(parent, activation.childId, activation.parentSession) + this.activations.authorizeLineage(parent, activation.childId, activation.parent.id) spec.signal.throwIfAborted() const childId = activation.childId const childCreatedAt = Date.now() @@ -286,7 +285,7 @@ export class SubagentContinuationManager { const senderActivation = this.activations.get(sender.id) if (senderActivation !== undefined && senderActivation.driver.agent === sender - && senderActivation.parentSession === targetId) { + && senderActivation.parent.id === targetId) { options.signal.throwIfAborted() return this.sendToParent(senderActivation, sender, content) } @@ -419,7 +418,7 @@ export class SubagentContinuationManager { 'ACTIVATION_CLOSING', ) } - const parent = this.ctx.agents.get(activation.parentSession) + const parent = this.ctx.agents.get(activation.parent.id) if (parent !== activation.parent) { throw new SubagentError( 'direct parent is not live; the message was not delivered', diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index de9191cc50..4b68a64852 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -1,9 +1,9 @@ /** * The durable subagent-child descriptor: the versioned, model-hidden * `subagent/descriptor` session event that identifies every session-backed - * subagent and records whether it is one-shot or continuable. Continuable - * descriptors additionally preserve the declared composition required for - * cold resume. Providers append it turn-enclosed in the child's initial turn. + * subagent, including historical one-shot records. The manager appends each + * continuable child's descriptor inside its initial turn, preserving the + * declared composition required for cold resume. * * The descriptor deliberately snapshots explicit fields rather than the * merge-extensible `AgentOptions` object: an unrelated extension value cannot @@ -30,8 +30,8 @@ declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Durable identity and lifecycle mode of a session-backed subagent child, - * appended once by the establishing provider inside the child's initial - * turn, before its first request. Continuable records also carry their + * appended once inside the child's initial turn, before its first + * request. Continuable records also carry their * resumable composition. Log-only: it carries no `surfaceOp`, never enters * model history, and survives compaction. */ @@ -90,42 +90,8 @@ export type SubagentDescriptorData = | OneShotSubagentDescriptorData | ContinuableSubagentDescriptorData -/** Fields shared by descriptor snapshot inputs. */ -interface SubagentDescriptorInputBase { - /** Whether the child is a terminal one-shot run or a resumable conversation. */ - readonly mode: 'one-shot' | 'continuable' - /** The `ctx.subagents` provider name that will establish the child. */ - readonly provider: string -} - -/** Input for a one-shot child's durable identity. */ -export interface OneShotSubagentDescriptorInput extends SubagentDescriptorInputBase { - readonly mode: 'one-shot' - /** Optional initial delegation `description` used as the durable creation label. */ - readonly label?: string -} - -/** Input for a continuable child's durable identity and resumable composition. */ -export interface ContinuableSubagentDescriptorInput extends SubagentDescriptorInputBase { - readonly mode: 'continuable' - /** Initial delegation `description` used for durable enumeration. */ - readonly label: string - /** Requested child `agentOptions.provider`. */ - readonly agentProvider?: string - /** Requested child `agentOptions.model`. */ - readonly agentModel?: string - /** Requested child `agentOptions.reasoningEffort`. */ - readonly agentReasoningEffort?: ReasoningEffortId - /** Requested per-child persona. */ - readonly persona?: string - /** Requested child tool scoping. */ - readonly toolFilter?: ToolRestriction -} - -/** Inputs {@link snapshotSubagentDescriptor} validates and detaches. */ -export type SubagentDescriptorInput = - | OneShotSubagentDescriptorInput - | ContinuableSubagentDescriptorInput +/** Continuable composition fields before the snapshot stamps their format version. */ +export type ContinuableSubagentDescriptorInput = Omit const DESCRIPTOR_BASE_KEYS = [ 'version', @@ -255,18 +221,6 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef } } -/** - * 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: OneShotSubagentDescriptorInput, -): OneShotSubagentDescriptorData /** * Validate and detach a continuable descriptor input. * @param input - the caller-collected continuable composition fields. @@ -275,26 +229,18 @@ export function snapshotSubagentDescriptor( */ export function snapshotSubagentDescriptor( input: ContinuableSubagentDescriptorInput, -): ContinuableSubagentDescriptorData -export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): SubagentDescriptorData { - const candidate: SubagentDescriptorData = input.mode === 'one-shot' - ? { - version: SUBAGENT_DESCRIPTOR_VERSION, - mode: input.mode, - provider: input.provider, - ...input.label !== undefined ? { label: input.label } : {}, - } - : { - version: SUBAGENT_DESCRIPTOR_VERSION, - mode: input.mode, - provider: input.provider, - label: input.label, - ...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {}, - ...input.agentModel !== undefined ? { agentModel: input.agentModel } : {}, - ...input.agentReasoningEffort !== undefined ? { agentReasoningEffort: input.agentReasoningEffort } : {}, - ...input.persona !== undefined ? { persona: input.persona } : {}, - ...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {}, - } +): ContinuableSubagentDescriptorData { + const candidate: ContinuableSubagentDescriptorData = { + version: SUBAGENT_DESCRIPTOR_VERSION, + mode: input.mode, + provider: input.provider, + label: input.label, + ...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {}, + ...input.agentModel !== undefined ? { agentModel: input.agentModel } : {}, + ...input.agentReasoningEffort !== undefined ? { agentReasoningEffort: input.agentReasoningEffort } : {}, + ...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') diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 1d35ee472e..3ee490a80b 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -74,7 +74,6 @@ export { SubagentRunId } from './types.ts' export type { ContinuableCreateRequest, ContinuableCreateSpec, - ResolvedSubagentStartRequest, SubagentCapabilities, SubagentActivation, SubagentActivationSpec, @@ -96,9 +95,7 @@ export type { ContinuableSubagentDescriptorData, ContinuableSubagentDescriptorInput, OneShotSubagentDescriptorData, - OneShotSubagentDescriptorInput, SubagentDescriptorData, - SubagentDescriptorInput, } from './descriptor.ts' export { STRUCTURED_OUTPUT_TOOL } from './structured.ts' export { SubagentError } from './error.ts' diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 739985f307..86ca31e606 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -1,14 +1,8 @@ /** * Lifecycle-edge publication for managed subagent activations. * - * 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. + * Public event payloads live in `./types.ts`; the package-private observer + * retains only the identity shared by each start/end pair. * * @module @deepseek-ai/dsh-subagent/lifecycle */ @@ -16,27 +10,11 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { foldConsumedWork } from '@deepseek-ai/dsh-agent' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { SubagentRunId } from './types.ts' import type { SubagentResult, SubagentRunEndInfo, SubagentRunInfo } from './types.ts' -/** - * How one Activation's residency epoch ended, as both the terminal lifecycle - * edge and the manager's own parent delivery report it. - */ -export interface ActivationTerminal { - /** Why this epoch's last ordinary turn ended, or `error` when teardown failed. */ - readonly stopReason: SubagentResult['stopReason'] - /** The epoch's final assistant content, absent when it produced none or failed. */ - readonly output?: readonly ContentBlock[] - /** Structured result retained for program consumers. */ - readonly structured?: unknown - /** Backend-authored failure detail. */ - readonly diagnostic?: string -} - /** * Lifecycle observer for one activation's residency epoch. Package-private: the * continuation manager is the only consumer, and its call ordering is an @@ -45,28 +23,17 @@ export interface ActivationTerminal { export interface ActivationObserver { /** * Publish the start edge once the epoch is resident. - * @param child - the resident child agent, whose log suffix bounds this epoch. + * @param child - the resident local Agent, or undefined for an external execution. */ start(child?: Agent): void - /** Record a backend-owned terminal result. */ - captureResult(result: SubagentResult): void - /** - * Resolve the terminal facts {@link settle} will publish, without publishing - * them. The manager's parent delivery must run before the ownership release - * that lets the parent settle, which is earlier than the terminal edge; both - * therefore read one computation instead of restating the failure rule. - * @param failure - the teardown or durability failure, or `undefined` on success. - * @returns this epoch's stop reason and final assistant content. - */ - terminal(failure: unknown): ActivationTerminal /** * 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. + * @param result - the captured result, with teardown failures applied. */ - settle(failure: unknown): void + settle(result: SubagentResult): void } /** @@ -133,28 +100,16 @@ export function createActivationObserver( parent: Agent, ): ActivationObserver { const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: false } - let captured: ActivationTerminal = { stopReason: 'completed' } - // Teardown failure overrides the epoch's own outcome and withholds its - // output: an answer this harness could not durably release is not a result. - const terminal = (failure: unknown): ActivationTerminal => failure === undefined - ? captured - : { stopReason: 'error' } return { start: (child?: Agent): void => { identity.local = child !== undefined emit('subagent/start', identity, parent) }, - captureResult: (result: SubagentResult): void => { - const { output, ...rest } = result - captured = { ...rest, ...output.length === 0 ? {} : { output } } - }, - terminal, - settle: (failure: unknown): void => { - const { stopReason, output } = terminal(failure) + settle: ({ stopReason, output }: SubagentResult): void => { emit('subagent/end', { ...identity, stopReason, - ...output === undefined ? {} : { lastAssistantMessage: output }, + ...output.length === 0 ? {} : { lastAssistantMessage: output }, }, parent) }, } diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index f2237e9349..c52d9f3bcc 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -47,7 +47,6 @@ interface ListingRuntime { readonly query: SessionQueryEngine readonly cache: SessionProjectionCache | undefined readonly corpus: ReadonlyMap - readonly catalogs: Map readonly subagentParents: ReadonlySet } @@ -109,39 +108,18 @@ export async function listDescendants( ): Promise { const listing = await prepareListing(ctx, signal) const positioned = descendantCandidates(listing.corpus, rootSessionId) - const candidates = positioned.filter(position => position.record.header.origin === 'subagent') - const resolved = await resolveCandidateRows(candidates.map(candidate => candidate.record), listing, signal) - const rows = new Map(candidates.map((candidate, index) => [candidate.record.header.id, resolved[index]])) + const rows = await resolveCandidateRows( + positioned.map(candidate => candidate.record), + listing, + signal, + ) const entries: SubagentDescendantListEntry[] = [] - const root = listing.corpus.get(rootSessionId) - const parents = root === undefined ? positioned : [{ record: root, parentId: rootSessionId, depth: 0 }, ...positioned] - for (const position of parents) { - const parentId = position.record.header.id - const row = rows.get(parentId) - if (row?.kind === 'diagnostic') { + positioned.forEach((position, index) => { + const row = rows[index] + if (row !== undefined) { entries.push({ ...row, parentId: position.parentId, depth: position.depth }) - continue } - let catalog: SubagentCatalogEntry[] | undefined - try { - catalog = listing.catalogs.get(parentId) - ?? (position.record.live === undefined - ? await listChildren(ctx, parentId, signal) - : listing.projections.snapshot(position.record.live, ['subagentCatalog']).values.subagentCatalog) - } catch (error: unknown) { - assertListingNotCancelled(signal) - throw error - } - if (catalog === undefined) throw new SubagentError('subagentCatalog projection is unavailable', 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') - if (row !== undefined) entries.push({ - ...row, parentId: position.parentId, depth: position.depth, - hasChildren: row.hasChildren || catalog.some(child => child.mode === 'one-shot' && child.external !== undefined), - }) - for (const child of catalog) { - if (child.mode !== 'one-shot' || child.external === undefined) continue - entries.push({ ...child, kind: 'child', activity: 'inactive', hasChildren: false, parentId, depth: position.depth + 1 }) - } - } + }) return entries } @@ -206,7 +184,7 @@ async function prepareListing( subagentParents.add(record.header.parentSession) } } - return { projections, query, cache, corpus, subagentParents, catalogs: new Map() } + return { projections, query, cache, corpus, subagentParents } } /** Resolve projection-backed rows for aligned candidates with bounded cold reads. */ @@ -228,9 +206,7 @@ async function resolveCandidateRows( // creation window before the establishing provider appends its descriptor. let identity: SubagentIdentityProjection | null | undefined try { - const values = projections.snapshot(candidate.live, ['subagent', 'subagentCatalog']).values - identity = values.subagent - if (values.subagentCatalog !== undefined) listing.catalogs.set(childId, values.subagentCatalog) + identity = projections.snapshot(candidate.live, ['subagent']).values.subagent } catch { // A rejecting identity fold is deterministic data damage in this child; // contain it as one diagnostic instead of failing the whole listing. @@ -252,7 +228,7 @@ async function resolveCandidateRows( async () => { for (let job = queue.shift(); job !== undefined; job = queue.shift()) { rows[job.index] = await resolveColdIdentity( - query, cache, listing.catalogs, job.header, + query, cache, job.header, subagentParents.has(job.header.id), signal, ) } @@ -290,7 +266,7 @@ function descendantCandidates( const id = position.record.header.id if (visited.has(id)) continue visited.add(id) - positioned.push(position) + if (position.record.header.origin === 'subagent') positioned.push(position) const descendants = children.get(id) ?? [] for (const record of [...descendants].reverse()) { stack.push({ record, parentId: id, depth: position.depth + 1 }) @@ -315,7 +291,6 @@ function compareCorpusRecords(a: CorpusRecord, b: CorpusRecord): number { async function resolveColdIdentity( query: SessionQueryEngine, cache: SessionProjectionCache | undefined, - catalogs: Map, header: SessionHeader, hasChildren: boolean, signal: AbortSignal | undefined, @@ -328,9 +303,7 @@ async function resolveColdIdentity( if (cache !== undefined && !header.isSeeded) { let cached: SubagentIdentityProjection | null | undefined try { - const values = cache.cachedSnapshot(header, ['subagent', 'subagentCatalog'])?.values - cached = values?.subagent - if (values?.subagentCatalog !== undefined) catalogs.set(childId, values.subagentCatalog) + cached = cache.cachedSnapshot(header, ['subagent'])?.values.subagent } catch { // Unlike the preparation fold below, a throwing cache read renders no // verdict: the cache is derived data, so its damage (a poisoned stored @@ -372,9 +345,7 @@ async function resolveColdIdentity( if (!sameLifecycle(ownedObservation.header, header)) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } - const values = ownedObservation.projections?.values - if (values?.subagentCatalog !== undefined) catalogs.set(childId, values.subagentCatalog) - const identity = values?.subagent + const identity = ownedObservation.projections?.values.subagent if (identity === undefined || identity === null || identity.seq < ownedObservation.inheritedEventCount) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index adfc566afb..126ec28947 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -240,13 +240,12 @@ export interface SubprocessRunHandleParts { * local cancellation — there is no assumption the child cooperates — and then * awaits the backend's teardown to actual exit. * @param parts - the run identity, result, cancellation wiring, and teardown. - * @returns the seam run handle (`localAgent` is `undefined` for remote runs). + * @returns the external run handle. */ export function subprocessRunHandle(parts: SubprocessRunHandleParts): SubagentRun { let disposal: Promise | undefined return { id: parts.id, - localAgent: undefined, result: parts.result, dispose(): Promise { if (disposal !== undefined) return disposal diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 478ac59e8f..b0a4a8bf97 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -14,7 +14,6 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { ContentBlock, MessageId } 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'> @@ -132,7 +131,7 @@ export interface SubagentCapabilities { /** * Task and optional capabilities supplied to a backend. startActivation carries * these fields in request while owning its label, cancellation, and delivery - * policy separately. Providers receive a resolved descriptor before start. + * policy separately. */ export interface SubagentStartRequest { /** Optional short display label persisted with a session-backed child. */ @@ -192,15 +191,6 @@ export interface SubagentStartRequest { readonly persona?: string } -/** - * Provider-facing execution request after {@link SubagentRuntime.startActivation} resolves - * the durable child descriptor. - */ -export interface ResolvedSubagentStartRequest extends SubagentStartRequest { - /** Detached descriptor a session-backed provider persists in the child log. */ - readonly descriptor: SubagentDescriptorData -} - /** * What the continuation manager asks a provider for while materializing one * continuable child's FIRST activation. The manager has already reserved the @@ -296,18 +286,8 @@ export interface SubagentResult { * through result. */ export interface SubagentRun { - /** - * Parent-scoped run id. For a local run, this MUST equal the published child - * session id, whose `parentSession` records `request.parent.session.id`; a - * remote provider mints an id unique in the parent namespace. - */ + /** Provider-minted run id, unique in the parent namespace. */ readonly id: SessionId - /** - * The exact published in-process child, or `undefined` for a remote run. - * When present, its id is {@link id}; the provider retains no ownership - * implication beyond the run's ordinary {@link dispose} contract. - */ - readonly localAgent: Agent | undefined /** * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport @@ -352,15 +332,13 @@ export interface SubagentProvider { /** * Establish one external execution and return its owned handle. * The service has already validated that every requested start-time - * capability is supported and resolved `request.descriptor`, so a - * session-backed implementation appends that descriptor inside the child's - * initial turn. Before fulfillment, the provider owns setup and cleans any - * unpublished partial resources before rejecting. Ownership transfers on + * capability is supported. Before fulfillment, the provider owns setup and + * cleans any unpublished partial resources before rejecting. Ownership transfers on * fulfillment; subsequent turn or infrastructure failure settles through * the returned run. Distinct starts may overlap; cancellation, failure, * result settlement, and disposal remain independent for each run. */ - start?(request: ResolvedSubagentStartRequest): Promise + start?(request: SubagentStartRequest): Promise /** * OPTIONAL (continuable-creation capability): contribute the detached * creation inputs that distinguish this provider's continuable children — diff --git a/packages/subagent/subagent/tests/activation-driver.spec.ts b/packages/subagent/subagent/tests/activation-driver.spec.ts index a0fcc2c132..06e89e9fce 100644 --- a/packages/subagent/subagent/tests/activation-driver.spec.ts +++ b/packages/subagent/subagent/tests/activation-driver.spec.ts @@ -10,7 +10,7 @@ it('cannot close or expose a result before the backend settles', async () => { const dispose = vi.fn(() => Promise.resolve()) const controller = new AbortController() const driver = new ExternalActivationDriver({ - id: SessionId('pending-external'), localAgent: undefined, result: result.promise, dispose, + id: SessionId('pending-external'), result: result.promise, dispose, }, controller) const close = vi.fn() expect(driver.version).toBe(0) @@ -33,7 +33,7 @@ describe('external driver infrastructure rejection', () => { const failure = new Error('transport unavailable') const dispose = vi.fn(() => Promise.resolve()) const driver = new ExternalActivationDriver({ - id: SessionId('failed-external'), localAgent: undefined, result: result.promise, dispose, + id: SessionId('failed-external'), result: result.promise, dispose, }, new AbortController()) result.reject(failure) await driver.whenIdle() diff --git a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts index 9e1c2a191d..22d59cb9b0 100644 --- a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts +++ b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts @@ -78,6 +78,8 @@ it('continues the parent through default Messages after a reasoning-bearing cont const notice = parent.session.deriveMessages().find(message => message.source.kind === 'subagent-settled') expect(notice?.content).toEqual([ { type: 'text', text: `Background subagent ${started.childId} finished and will do no further work unless you send it more.` }, + { type: 'text', text: 'Its closing message:' }, + { type: 'text', text: 'child answer' }, ]) expect(requests[1]?.body).toMatchObject({ messages: [{ role: 'user', content: notice?.content }] }) diff --git a/packages/subagent/subagent/tests/continuation-messages.spec.ts b/packages/subagent/subagent/tests/continuation-messages.spec.ts index 55793adac4..ad8962fb8d 100644 --- a/packages/subagent/subagent/tests/continuation-messages.spec.ts +++ b/packages/subagent/subagent/tests/continuation-messages.spec.ts @@ -26,12 +26,11 @@ describe('continuable settlement content', () => { }) it.each([ - ['absent output', undefined], ['empty output', []], ['reasoning-only output', [reasoning]], ['empty text', [{ type: 'text', text: '' }]], - ] satisfies [string, ContentBlock[] | undefined][])('reports no closing message for %s', (_label, output) => { - const message = createSettlementMessage(childId, { stopReason: 'completed', ...output === undefined ? {} : { output } }) + ] satisfies [string, ContentBlock[]][])('reports no closing message for %s', (_label, output) => { + const message = createSettlementMessage(childId, { stopReason: 'completed', output }) expect(message.content).toEqual([ summary, diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 758082b3fc..a123427b18 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -13,6 +13,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as toolSchedule from '@deepseek-ai/dsh-schedule' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' +import * as SubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import type { ContentBlock, GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' import { ToolCallId, createUserMessage, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -2744,6 +2745,7 @@ describe('continuable adjacent-Agent delivery', () => { describe('continuable settlement delivery', () => { it('reports local completion without repeating the child answer', async () => { const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')]) + await ctx.plugin(SubagentControl) const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) @@ -2785,7 +2787,7 @@ describe('continuable settlement delivery', () => { await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} ran out of room before it finished.`, + `Background subagent ${started.childId} ran out of room before it finished.\nIts closing message:\nhalf an ans`, ) }) @@ -2803,7 +2805,7 @@ describe('continuable settlement delivery', () => { await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} declined the task.`, + `Background subagent ${started.childId} declined the task.\nIt left no closing message.`, ) }) @@ -2833,7 +2835,7 @@ describe('continuable settlement delivery', () => { const child = await loadStoredSession(ctx.sessionPersistence, started.childId) expect(hasUserText(child.events, 'second task')).toBe(false) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} failed before it finished.`, + `Background subagent ${started.childId} failed before it finished.\nIts closing message:\nthe answer`, ) }) @@ -2868,7 +2870,7 @@ describe('continuable settlement delivery', () => { // task had finished. await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.`, + `Background subagent ${started.childId} was stopped before it finished.\nIts closing message:\nthe answer`, ) }) @@ -2894,7 +2896,7 @@ describe('continuable settlement delivery', () => { // that genuinely finished with no output. await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.`, + `Background subagent ${started.childId} was stopped before it finished.\nIt left no closing message.`, ) }) @@ -2920,7 +2922,7 @@ describe('continuable settlement delivery', () => { await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.`, + `Background subagent ${started.childId} was stopped before it finished.\nIt left no closing message.`, ) }) @@ -2961,7 +2963,7 @@ describe('continuable settlement delivery', () => { expect(hasUserText(child.session.snapshotEvents(), 'never runs')).toBe(false) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.`, + `Background subagent ${started.childId} was stopped before it finished.\nIts closing message:\nthe answer`, ) }) @@ -2982,7 +2984,7 @@ describe('continuable settlement delivery', () => { await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} failed before it finished.`, + `Background subagent ${started.childId} failed before it finished.\nIt left no closing message.`, ) }) @@ -3122,7 +3124,7 @@ describe('continuable settlement delivery', () => { // message when that parent is disposed next is pinned by the test below. expect(settlementNotices(parent)).toHaveLength(1) expect(settlementNotices(parent)[0]!.text).toBe( - `Background subagent ${started.childId} was stopped before it finished.`, + `Background subagent ${started.childId} was stopped before it finished.\nIt left no closing message.`, ) expect(parent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced')).toBe(true) expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) diff --git a/packages/subagent/subagent/tests/external-activation.spec.ts b/packages/subagent/subagent/tests/external-activation.spec.ts index f6b65d0787..09341fedae 100644 --- a/packages/subagent/subagent/tests/external-activation.spec.ts +++ b/packages/subagent/subagent/tests/external-activation.spec.ts @@ -6,7 +6,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '../src/index.ts' import { TestSessionQuery } from './test-session-query.ts' -import type { ResolvedSubagentStartRequest, SubagentCapabilities, SubagentResult, SubagentRun } from '../src/types.ts' +import type { SubagentStartRequest, SubagentCapabilities, SubagentResult, SubagentRun } from '../src/types.ts' import { externalTestParent } from './external-activation-helpers.ts' import { continuationManager } from './continuation-internals.ts' @@ -14,7 +14,7 @@ const capabilities: SubagentCapabilities = { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false, } -async function setup(start: (request: ResolvedSubagentStartRequest) => Promise) { +async function setup(start: (request: SubagentStartRequest) => Promise) { const ctx = new Context() await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) @@ -37,7 +37,7 @@ function execution() { const cleanup = Promise.withResolvers() const cleaning = Promise.withResolvers() const dispose = vi.fn(() => { cleaning.resolve(undefined); return cleanup.promise }) - const run: SubagentRun = { id: SessionId('external-task'), localAgent: undefined, result: result.promise, dispose } + const run: SubagentRun = { id: SessionId('external-task'), result: result.promise, dispose } return { result, cleanup, cleaning, dispose, run } } @@ -166,7 +166,7 @@ describe('external subagent activations', () => { const result = Promise.withResolvers() const dispose = vi.fn(async () => { result.resolve({ output: [], stopReason: 'aborted' }) }) const fixture = await setup(async () => ({ - id: SessionId('dispose-settled'), localAgent: undefined, result: result.promise, dispose, + id: SessionId('dispose-settled'), result: result.promise, dispose, })) const activation = await fixture.start() await activation.dispose() @@ -256,9 +256,7 @@ it('discovers an external leaf through the parent catalog and publishes a small const activation = await fixture.start() const expected = { id: activation.childId, mode: 'one-shot', label: 'External work', external: 'pending' } expect(await fixture.ctx.subagents.listChildren(fixture.parent.id)).toMatchObject([expected]) - expect(await fixture.ctx.subagents.listDescendants(fixture.parent.id)).toMatchObject([ - { ...expected, kind: 'child', parentId: fixture.parent.id, depth: 1, hasChildren: false }, - ]) + expect(await fixture.ctx.subagents.listDescendants(fixture.parent.id)).toEqual([]) expect(fixture.ctx.sessions.get(activation.childId)).toBeUndefined() const result: SubagentResult = { output: [{ type: 'text', text: 'x'.repeat(100_000) }], stopReason: 'completed' } backend.result.resolve(result) diff --git a/packages/subagent/subagent/tests/external-lifecycle.spec.ts b/packages/subagent/subagent/tests/external-lifecycle.spec.ts index 2a291d205f..343fb1ad09 100644 --- a/packages/subagent/subagent/tests/external-lifecycle.spec.ts +++ b/packages/subagent/subagent/tests/external-lifecycle.spec.ts @@ -6,12 +6,12 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '../src/index.ts' -import type { ResolvedSubagentStartRequest, SubagentRun, SubagentResult } from '../src/types.ts' +import type { SubagentStartRequest, SubagentRun, SubagentResult } from '../src/types.ts' import { externalTestParent } from './external-activation-helpers.ts' const complete: SubagentResult = { output: [], stopReason: 'completed' } -async function setup(start: (request: ResolvedSubagentStartRequest) => Promise) { +async function setup(start: (request: SubagentStartRequest) => Promise) { const ctx = new Context() await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) @@ -31,7 +31,7 @@ async function setup(start: (request: ResolvedSubagentStartRequest) => Promise, dispose = () => Promise.resolve()): SubagentRun { - return { id: SessionId(id), result, localAgent: undefined, dispose } + return { id: SessionId(id), result, dispose } } describe('external activation ownership', () => { diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index f87f7d7c65..e30e95b0e6 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -621,7 +621,7 @@ describe('SubagentRuntime.listDescendants', () => { }, childEvents(descriptorPayload('disk label'))) ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: SessionSeq(2), - values: { subagent: { mode: 'one-shot', seq: SessionSeq(2) }, subagentCatalog: [] }, + values: { subagent: { mode: 'one-shot', seq: SessionSeq(2) } }, }) const open = vi.spyOn(ctx.sessionPersistence, 'open') @@ -783,38 +783,7 @@ describe('SubagentRuntime.listDescendants', () => { }) -it('includes external leaves beneath local children and ordinary Session intermediates', async () => { - const { ctx, parent } = await setup([]) - const child = ctx.sessions.create(SessionId('catalog-local'), { meta: { parentSession: parent.id, origin: 'subagent' } }) - child.append('subagent/descriptor', descriptorPayload('Local')) - const ordinary = ctx.sessions.create(SessionId('catalog-ordinary'), { meta: { parentSession: parent.id } }) - for (const session of [child, ordinary]) { - session.append('subagent/catalog', { version: 0, childId: SessionId(`${session.id}-external`), childCreatedAt: 1, - mode: 'one-shot', label: 'External', external: 'pending' }) - } - const rows = await ctx.subagents.listDescendants(parent.id) - expect(rows).toContainEqual(expect.objectContaining({ id: child.id, hasChildren: true })) - for (const session of [child, ordinary]) { - expect(rows).toContainEqual(expect.objectContaining({ id: `${session.id}-external`, parentId: session.id, - depth: 2, mode: 'one-shot', external: 'pending', hasChildren: false })) - } -}) - - it('lists an absent root with no recorded descendants as empty', async () => { const { ctx } = await setup([]) expect(await ctx.subagents.listDescendants(SessionId('absent-root'))).toEqual([]) }) - -it('reports an unreadable ordinary intermediary instead of silently losing its external catalog', async () => { - const { ctx, parent } = await setup([]) - await authorChild(ctx, 'ordinary-unreadable', { parentSession: parent.id }, []) - vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('catalog unavailable')) - await expect(ctx.subagents.listDescendants(parent.id)).rejects.toThrow('catalog unavailable') -}) - -it('requires the catalog projection when enumerating an ordinary parent', async () => { - const { ctx, parent } = await setup([]) - vi.spyOn(ctx.sessionProjections, 'snapshot').mockReturnValue({ asOfSeq: SessionSeq(0), values: {} }) - await expect(ctx.subagents.listDescendants(parent.id)).rejects.toMatchObject({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) -}) diff --git a/packages/subagent/subagent/tests/out-of-process.spec.ts b/packages/subagent/subagent/tests/out-of-process.spec.ts index 98b670f36f..71d9a942cc 100644 --- a/packages/subagent/subagent/tests/out-of-process.spec.ts +++ b/packages/subagent/subagent/tests/out-of-process.spec.ts @@ -177,7 +177,6 @@ describe('subprocessRunHandle', () => { requestCancel, teardown, }) - expect(run.localAgent).toBeUndefined() expect(String(run.id)).toBe('run-1') const disposal = run.dispose() expect(run.dispose()).toBe(disposal) diff --git a/packages/subagent/subagent/tests/parent-result.spec.ts b/packages/subagent/subagent/tests/parent-result.spec.ts new file mode 100644 index 0000000000..becc6e270a --- /dev/null +++ b/packages/subagent/subagent/tests/parent-result.spec.ts @@ -0,0 +1,43 @@ +/** Parent result delivery when a local child's messaging tool is unavailable. */ + +import { expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +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 * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' +import * as Control from '@deepseek-ai/dsh-tool-subagent-control' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import SubagentRuntime from '../src/index.ts' +import { mountLocalActivations } from './local-activation.ts' + +it.each(['absent', 'filtered'] as const)('returns the closing answer when send_message is %s', async (availability) => { + const ctx = new Context() + await mountLocalActivations(ctx) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentRuntime) + await ctx.plugin(Spawn, { providerName: 'spawn' }) + if (availability === 'filtered') await ctx.plugin(Control) + const adapter = new MockAdapter([textResponse('CHILD_RESULT_42'), textResponse('Parent received the answer.')]) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const activation = await ctx.subagents.startActivation({ + provider: 'spawn', label: 'Compute the answer', delivery: 'parent', signal: new AbortController().signal, + request: { + parent, prompt: [{ type: 'text', text: 'Find the answer.' }], + ...availability === 'filtered' ? { toolFilter: { deny: ['send_message'] } } : {}, + }, + }) + try { + await ctx.subagents.waitForChildren(parent) + await parent.whenIdle() + expect(adapter.requests).toHaveLength(2) + expect(adapter.requests[0]!.tools?.some(tool => tool.name === 'send_message') ?? false).toBe(false) + const notices = adapter.requests[1]!.messages.filter(message => message.role === 'user') + expect(JSON.stringify(notices)).toContain('CHILD_RESULT_42') + expect(JSON.stringify(notices)).toContain('Its closing message:') + } finally { + await activation.dispose() + } +}) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index b027063d6e..4774114cc6 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -12,7 +12,6 @@ import SubagentRuntime, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError, assertSubagentMaxDepth, - type ResolvedSubagentStartRequest, type SubagentCapabilities, type SubagentActivation, type SubagentActivationSpec, @@ -44,7 +43,7 @@ function baseRequest(overrides: Partial = {}): SubagentSta class StubProvider implements SubagentProvider { readonly inheritsParentContext = false startCount = 0 - lastRequest: ResolvedSubagentStartRequest | undefined + lastRequest: SubagentStartRequest | undefined constructor( readonly name: string, @@ -55,12 +54,11 @@ class StubProvider implements SubagentProvider { }, ) {} - async start(request: ResolvedSubagentStartRequest): Promise { + async start(request: SubagentStartRequest): Promise { this.startCount += 1 this.lastRequest = request return { id: SessionId(`child:${this.name}:${request.parent.id}`), - localAgent: undefined, result: Promise.resolve(this.outcome), async dispose() {}, } @@ -158,7 +156,7 @@ describe('SubagentRuntime', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) }) - it('resolves the one-shot descriptor and exposes no provider continuation operations', async () => { + it('forwards external execution options and exposes no provider continuation operations', async () => { const { subagents } = await service() const provider = new StubProvider('one-shot') subagents.registerProvider(provider) @@ -170,12 +168,6 @@ describe('SubagentRuntime', () => { label: 'one-shot', parent: provider.lastRequest!.parent, signal: provider.lastRequest!.signal, - descriptor: { - version: SUBAGENT_DESCRIPTOR_VERSION, - mode: 'one-shot', - provider: 'one-shot', - label: 'one-shot', - }, }) expect(provider.lastRequest).not.toBe(request) expectTypeOf[0]>().toEqualTypeOf() @@ -282,7 +274,7 @@ describe('SubagentRuntime', () => { const starting = start(subagents, 'deferred', baseRequest({ parent })) await Promise.resolve() expect(events).toEqual([]) - ready.resolve({ id: SessionId('child'), localAgent: undefined, result: result.promise, async dispose() {} }) + ready.resolve({ id: SessionId('child'), result: result.promise, async dispose() {} }) const run = await starting expect(events).toEqual(['start']) result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' }) @@ -344,7 +336,6 @@ describe('SubagentRuntime', () => { inheritsParentContext: false, start: () => Promise.resolve({ id: childId, - localAgent: undefined, result: result.promise, dispose, }), @@ -398,7 +389,7 @@ describe('SubagentRuntime', () => { capabilities: NO_CAPS, inheritsParentContext: false, async start() { - return { id: SessionId('infra-child'), localAgent: undefined, result: failure.promise, async dispose() {} } + return { id: SessionId('infra-child'), result: failure.promise, async dispose() {} } }, }) const failedRun = await start(subagents, 'infra', baseRequest()) @@ -445,18 +436,17 @@ describe('subagent descriptors', () => { it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => { expect(foldSubagentDescriptor([])).toBeUndefined() - const minimal = snapshotSubagentDescriptor({ mode: 'one-shot', provider: 'spawn' }) - expect(minimal).toEqual({ + const minimal = { version: SUBAGENT_DESCRIPTOR_VERSION, - mode: 'one-shot', + mode: 'one-shot' as const, provider: 'spawn', - }) + } expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal) + expect(foldSubagentDescriptor([event({ ...minimal, label: 'child work' })])) + .toEqual({ ...minimal, label: 'child work' }) expect(snapshotSubagentDescriptor({ - mode: 'one-shot', - provider: 'spawn', - label: 'child work', - })).toEqual({ ...minimal, label: 'child work' }) + mode: 'continuable', provider: 'spawn', label: 'child', + })).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 'spawn', label: 'child' }) const complete = { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable' as const, diff --git a/packages/subagent/subagent/tests/structured.spec.ts b/packages/subagent/subagent/tests/structured.spec.ts index d6fba938f4..01b7d2a21e 100644 --- a/packages/subagent/subagent/tests/structured.spec.ts +++ b/packages/subagent/subagent/tests/structured.spec.ts @@ -12,6 +12,7 @@ import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentRuntime, { type SubagentStartRequest, type SubagentActivation, + type SubagentResult, } from '@deepseek-ai/dsh-subagent' import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools' import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' @@ -336,6 +337,48 @@ describe('in-process structured output', () => { } }) + it('waits through interim idle while delegated work supplies the final structured answer', async () => { + const { ctx, parent } = await setup([ + toolCallResponse('delegate', 'delegate_answer', {}), + textResponse('Waiting for the delegated answer.'), + toolCallResponse('capture', STRUCTURED_OUTPUT_TOOL, { answer: 42 }), + ]) + const delegated = Promise.withResolvers() + ctx.subagents.registerProvider({ + name: 'delayed-answer', + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: () => Promise.resolve({ + id: SessionId('delegated-answer'), + result: delegated.promise, + dispose: () => { delegated.resolve({ output: [], stopReason: 'aborted' }); return Promise.resolve() }, + }), + }) + ctx.tools.register(defineContentToolFixture({ + name: 'delegate_answer', description: 'Delegate answer computation', parameters: {}, + async execute(_args, exec): Promise { + if (exec.agent === undefined) throw new Error('expected the structured parent') + await ctx.subagents.startActivation({ + provider: 'delayed-answer', label: 'Compute answer', signal: testToolSignal, delivery: 'parent', + request: { parent: exec.agent, prompt: [{ type: 'text', text: 'Compute the answer' }] }, + }) + return [{ type: 'text', text: 'Answer computation accepted' }] + }, + })) + const activation = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) + let resultReady = false + void activation.result.then(() => { resultReady = true }) + try { + await activation.localAgent.whenIdle() + await Promise.resolve() + expect(resultReady).toBe(false) + delegated.resolve({ output: [{ type: 'text', text: 'The answer is 42.' }], stopReason: 'completed' }) + await expect(activation.result).resolves.toMatchObject({ stopReason: 'completed', structured: { answer: 42 } }) + } finally { + await activation.dispose() + } + }) + it('rejects new input after capture until closure, then cold-resumes without the schema', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('capture', STRUCTURED_OUTPUT_TOOL, { answer: 42 }), @@ -724,9 +767,7 @@ describe('in-process structured output', () => { }) it('a failed execution stage is discarded and never promoted by a later call', async () => { - const { ctx, parent } = await setup([ - toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), - ]) + const { ctx, parent } = await setup(['hang']) const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) // A prepended post-execute listener blocks the first capture without // delegating. The final-result notification discards that execution's @@ -739,13 +780,16 @@ describe('in-process structured output', () => { } return next() }, { prepend: true }) - const result = await run.result + const failed = await ctx.tools.execute({ + signal: testToolSignal, + callId: ToolCallId('c1'), + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 1 }, + agent: run.localAgent, + }) + expect(failed.isError).toBe(true) const child = run.localAgent - // The blocked capture must NOT surface as structured success… - expect(result.stopReason).toBe('error') - expect(result.structured).toBeUndefined() - // …and a LATER invalid call (its own body staged nothing) must not - // resurrect c1's discarded value: drive the pipeline directly. + // Invalid calls must not commit a preceding execution's discarded value. const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: 'c2' as never, @@ -764,12 +808,11 @@ describe('in-process structured output', () => { }) expect(valid.isError).toBeFalsy() await run.dispose() + expect((await run.result).structured).toEqual({ answer: 9 }) }) it('reusing a failed execution\'s call id never promotes its discarded stage', async () => { - const { ctx, parent } = await setup([ - toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), - ]) + const { ctx, parent } = await setup(['hang']) const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) // Block the first capture after its body stages a value. Its final error // discards that execution's stage. @@ -781,7 +824,14 @@ describe('in-process structured output', () => { } return next() }, { prepend: true }) - await run.result + const failed = await ctx.tools.execute({ + signal: testToolSignal, + callId: ToolCallId('c1'), + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 1 }, + agent: run.localAgent, + }) + expect(failed.isError).toBe(true) const child = run.localAgent // A SECOND capture call with the SAME call id whose body never stages // (invalid args throw before the stage): the discarded value must not ride @@ -804,12 +854,11 @@ describe('in-process structured output', () => { }) expect(valid.isError).toBeFalsy() await run.dispose() + expect((await run.result).structured).toEqual({ answer: 5 }) }) it('a pre-execute deny with call-id reuse cannot promote another execution\'s stage', async () => { - const { ctx, parent } = await setup([ - toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), - ]) + const { ctx, parent } = await setup(['hang']) const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) // Discard the first capture's stage via a final post-execute block. let blocks = 1 @@ -820,7 +869,14 @@ describe('in-process structured output', () => { } return next() }, { prepend: true }) - await run.result + const failed = await ctx.tools.execute({ + signal: testToolSignal, + callId: ToolCallId('c1'), + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 1 }, + agent: run.localAgent, + }) + expect(failed.isError).toBe(true) const child = run.localAgent // A prepended pre-execute deny skips the body, while the denied call still // reaches the final notification with the same adapter-minted call id. @@ -850,5 +906,6 @@ describe('in-process structured output', () => { }) expect(valid.isError).toBeFalsy() await run.dispose() + expect((await run.result).structured).toEqual({ answer: 5 }) }) }) diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index cd371280f7..0869edc8e9 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: ca207d6512f2719dda6aab5da391624e781be4df -README.zh.md: ced703d7b5c193164af5c04b43e5c04e5f20a9bb +README.md: c440ec469bf8264dd9ad294f1dd3332edd926f5a +README.zh.md: 03db9b84adee857ff9787de8e501ef02cd8528c9 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index ca207d6512..c440ec469b 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -56,7 +56,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Every call returns `started subagent ` after the runtime accepts the child. It does not wait for the child's result. The activation owns the work independently of the completed tool call; the runtime sends a completion notice and releases execution resources. -Local Spawn and Fork children report results with `send_message` and receive a separate completion-status notice. They can accept more work through `send_message` while active or resume after settling. Codex, Claude Code, ACP, and DSH SDK backends include their final answer in the completion notice and do not accept follow-up messages. +Local Spawn and Fork children report results with `send_message`; when that tool is unavailable, their completion notice carries the final answer. They can accept more work through `send_message` while active or resume after settling. Codex, Claude Code, ACP, and DSH SDK backends include their final answer in the completion notice and do not accept follow-up messages. `maxDepth` caps recursion (`0` forbids delegation); omission reads the current Host `subagent.maxDepth` setting, initially `1`, at each delegation. A numeric depth requires a provider with the `depthLimit` capability; `'provider-managed'` leaves the budget to an out-of-process provider. `persona` and `toolFilter` configure every child when the provider supports them, and the tool stays visible at the cap — each attempted start checks the calling agent's current depth and rejects with an errored result. @@ -188,7 +188,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Local children send their answers through `send_message`; the runtime separately reports their completion status. External children include the final answer in their runtime completion notice. The returned child id identifies subsequent control and catalog operations; only backends with continuation support accept follow-up messages. +Answer delivery follows the managed delegation rules above. The returned child id identifies subsequent control and catalog operations; only backends with continuation support accept follow-up messages. #### Token effect diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index ced703d7b5..03db9b84ad 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -56,7 +56,7 @@ kind: "package-reference" 运行时接受子任务后,每次调用都会返回 `started subagent `,不等待子任务结果。activation 独立于已完成的工具调用拥有这项工作;运行时发送完成通知并释放执行资源。 -本地 Spawn 和 Fork 子任务通过 `send_message` 汇报结果,并另行产生完成状态通知。它们可以在活跃期间通过 `send_message` 接受更多工作,或在结束后恢复。Codex、Claude Code、ACP 与 DSH SDK 后端在完成通知中附带最终回答,不接受后续消息。 +本地 Spawn 和 Fork 子任务通过 `send_message` 汇报结果;该工具不可用时,完成通知会附带最终回答。它们可以在活跃期间通过 `send_message` 接受更多工作,或在结束后恢复。Codex、Claude Code、ACP 与 DSH SDK 后端在完成通知中附带最终回答,不接受后续消息。 `maxDepth` 限制递归深度(`0` 禁止委派);省略时,每次委派读取 Host 当前的 `subagent.maxDepth` 设置,初始值为 `1`。数值深度要求提供方具备 `depthLimit` 能力;`'provider-managed'` 把预算留给进程外提供方。当提供方支持时,`persona` 与 `toolFilter` 会配置每个子 agent;工具在达到上限时仍然可见——每次尝试启动都会检查调用 agent 的当前深度,被拒绝时返回出错的工具结果。 @@ -188,7 +188,7 @@ Start independent delegations with `subagent` or `subagent_fork` together in one #### 模型看到什么 -本地子任务通过 `send_message` 发送回答;运行时另行汇报其完成状态。外部子任务在运行时完成通知中附带最终回答。返回的子任务 id 用于后续控制与目录操作;只有支持继续执行的后端才接受后续消息。 +回答投递遵循上文的受管理委派规则。返回的子任务 id 用于后续控制与目录操作;只有支持继续执行的后端才接受后续消息。 #### Token 影响 diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 1db6fb31ff..df25d2ad97 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -492,7 +492,7 @@ it('reads the saved default depth at each delegation without remounting the tool inheritsParentContext: false, start: async (request) => { depths.push(request.maxDepth) - return { id: SessionId(`depth-${depths.length}`), localAgent: undefined, + return { id: SessionId(`depth-${depths.length}`), result: Promise.resolve({ output: [], stopReason: 'completed' as const }), dispose: async () => {} } }, }) diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts index a8290e4bfe..96644ff2d5 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { type Agent } from '@deepseek-ai/dsh-agent' -import SubagentRuntime, { snapshotSubagentDescriptor, type ResolvedSubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SessionId } from '@deepseek-ai/dsh-session' import * as scripted from './scripted-provider.ts' @@ -11,10 +11,9 @@ function fakeParent(id = 'parent-1'): Agent { return { id: SessionId(id) } as unknown as Agent } -function baseRequest(over: Partial = {}): ResolvedSubagentStartRequest { +function baseRequest(over: Partial = {}): SubagentStartRequest { return { prompt: [{ type: 'text', text: 'task' }], - descriptor: snapshotSubagentDescriptor({ mode: 'one-shot', provider: 'mock' }), parent: fakeParent(), signal: new AbortController().signal, ...over, diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts index 01d250e877..73f1d75118 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -90,7 +90,6 @@ class ScriptedSubagentProvider implements SubagentProvider { return { id: SessionId(`scripted-subagent:${this.name}:${request.parent.id}:${++this.started}`), - localAgent: undefined, result, dispose(): Promise { state.cancelled = true diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 310f875743..307752116e 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -294,7 +294,6 @@ describe('dsh-tool-subagent', () => { seen = request return { id: SessionId('bare-child'), - localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -557,7 +556,6 @@ describe('dsh-tool-subagent', () => { seen = request return { id: SessionId('capture2-child'), - localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -612,7 +610,6 @@ describe('dsh-tool-subagent', () => { seen = request return { id: SessionId('capture3-child'), - localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -640,7 +637,6 @@ describe('dsh-tool-subagent', () => { seen = request return { id: SessionId('capture4-child'), - localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -826,7 +822,6 @@ describe('depth budget configuration', () => { requests.push(request) return { id: SessionId(`capture-child-${requests.length}`), - localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -876,7 +871,6 @@ describe('depth budget configuration', () => { requests.push(request) return { id: SessionId('external-child'), - localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } diff --git a/packages/test-support/session-snapshot/tests/fixtures/subagent-result-diagnostic.ts b/packages/test-support/session-snapshot/tests/fixtures/subagent-result-diagnostic.ts index 9725788b74..5a6a25d706 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/subagent-result-diagnostic.ts +++ b/packages/test-support/session-snapshot/tests/fixtures/subagent-result-diagnostic.ts @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import { NO_START_CAPABILITIES, - type ResolvedSubagentStartRequest, + type SubagentStartRequest, type SubagentProvider, } from '@deepseek-ai/dsh-subagent' import { SessionId } from '@deepseek-ai/dsh-session' @@ -40,7 +40,7 @@ class DiagnosticProvider implements SubagentProvider { readonly inheritsParentContext = false private starts = 0 - async start(request: ResolvedSubagentStartRequest) { + async start(request: SubagentStartRequest) { if (request.signal.aborted) { throw new Error('snapshot diagnostic provider start aborted') } @@ -51,7 +51,6 @@ class DiagnosticProvider implements SubagentProvider { } return { id: SessionId(fixture.id), - localAgent: undefined, result: Promise.resolve({ output: [...fixture.output], diagnostic: fixture.diagnostic, diff --git a/packages/workflow/workflow-ptc/tests/built-runtime.e2e.ts b/packages/workflow/workflow-ptc/tests/built-runtime.e2e.ts index f4add8114a..fe158b5f85 100644 --- a/packages/workflow/workflow-ptc/tests/built-runtime.e2e.ts +++ b/packages/workflow/workflow-ptc/tests/built-runtime.e2e.ts @@ -46,7 +46,7 @@ try { async start() { selectedStarts += 1 return { - id: 'built-child', localAgent: undefined, + id: 'built-child', result: Promise.resolve({ output: [], structured: { answer: 42 }, stopReason: 'completed' }), dispose: () => Promise.resolve(), } diff --git a/packages/workflow/workflow-ptc/tests/host.spec.ts b/packages/workflow/workflow-ptc/tests/host.spec.ts index e0d0764e83..9c00a5176d 100644 --- a/packages/workflow/workflow-ptc/tests/host.spec.ts +++ b/packages/workflow/workflow-ptc/tests/host.spec.ts @@ -40,7 +40,6 @@ async function setup(execute?: (bindings: HostBindings, spec: PtcRunSpec) => Pro inheritsParentContext: false, start: async () => ({ id: SessionId('host-child'), - localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'completed' }), dispose: () => Promise.resolve(), }), diff --git a/packages/workflow/workflow-ptc/tests/workflow-ptc.spec.ts b/packages/workflow/workflow-ptc/tests/workflow-ptc.spec.ts index a3d219c08b..1974142254 100644 --- a/packages/workflow/workflow-ptc/tests/workflow-ptc.spec.ts +++ b/packages/workflow/workflow-ptc/tests/workflow-ptc.spec.ts @@ -98,7 +98,6 @@ class StubProvider implements SubagentProvider { if (request.signal.aborted) throw new Error('child start aborted before publication') return { id: SessionId(`stub-child-${index}`), - localAgent: undefined, result: terminal.promise, dispose: () => { controlled.disposeCalls += 1 @@ -468,7 +467,6 @@ describe('dsh-workflow-ptc', { timeout: 120_000 }, () => { inheritsParentContext: false, start: async () => ({ id: SessionId('reject-child'), - localAgent: undefined, result: Promise.reject(new Error('backend exploded')), dispose: () => Promise.resolve(), }), @@ -528,7 +526,6 @@ describe('dsh-workflow-ptc', { timeout: 120_000 }, () => { inheritsParentContext: false, start: async () => ({ id: SessionId('bad-dispose-child'), - localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, dispose: () => { throw new Error('dispose exploded') }, @@ -552,7 +549,6 @@ describe('dsh-workflow-ptc', { timeout: 120_000 }, () => { inheritsParentContext: false, start: async () => ({ id: SessionId('trap-child'), - localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection IS the scenario under test @@ -777,7 +773,6 @@ describe('dsh-workflow-ptc', { timeout: 120_000 }, () => { }, { once: true }) return { id: SessionId('signal-only-child'), - localAgent: undefined, result, dispose: () => Promise.resolve(), } @@ -892,7 +887,6 @@ describe('dsh-workflow-ptc', { timeout: 120_000 }, () => { await writeFile(resource, 'owned') return { id: SessionId('late-published-child'), - localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'aborted' }), dispose: async () => { disposals += 1; await rm(resource) }, } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f4744070ef..e6f5d75471 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1376,11 +1376,6 @@ "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, - { - "doc": "docs/subsystems/subagent.md", - "symbol": "ResolvedSubagentStartRequest", - "source": "packages/subagent/subagent/src/types.ts" - }, { "doc": "docs/subsystems/subagent.md", "symbol": "AgentMessageSource", diff --git a/snapshots/session/subagent-no-message-tool/cordis.snapshot.yml b/snapshots/session/subagent-no-message-tool/cordis.snapshot.yml new file mode 100644 index 0000000000..cc98a6e14c --- /dev/null +++ b/snapshots/session/subagent-no-message-tool/cordis.snapshot.yml @@ -0,0 +1,50 @@ +# Replay patch shared by the ordinary headless snapshot composition. The model +# script comes from the scenario's committed session JSONL. + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + +- id: plugin-package-inventory-deepseek + disabled: true + +- id: session-title-llm + disabled: true + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + compression: none + +- 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 + +# Restrict inherited capabilities before the child assembles its first request. +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + maxDepth: 1 + toolFilter: + deny: [send_message, write, edit, glob, grep, web_search, web_fetch] diff --git a/snapshots/session/subagent-no-message-tool/cordis.yml b/snapshots/session/subagent-no-message-tool/cordis.yml new file mode 100644 index 0000000000..c2d806c129 --- /dev/null +++ b/snapshots/session/subagent-no-message-tool/cordis.yml @@ -0,0 +1,9 @@ +# Restrict inherited capabilities before the child assembles its first request. +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + maxDepth: 1 + toolFilter: + deny: [send_message, write, edit, glob, grep, web_search, web_fetch] diff --git a/snapshots/session/subagent-no-message-tool/session.1.v4.jsonl b/snapshots/session/subagent-no-message-tool/session.1.v4.jsonl new file mode 100644 index 0000000000..b656d3cf12 --- /dev/null +++ b/snapshots/session/subagent-no-message-tool/session.1.v4.jsonl @@ -0,0 +1,23 @@ +{"type":"session","version":4,"id":"{{session:2}}","createdAt":1789625685858,"cwd":"{{cwd}}","parentSession":"{{session:1}}","isSeeded":false,"origin":"subagent","delegationDepth":1} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash","toolFilter":{"deny":["send_message","write","edit","glob","grep","web_search","web_fetch"]}}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} +{"type":"approval/policy","data":{"policy":"never","source":"delegation"}} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:9}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:10}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:9}}"},"surfaceOp":"append"} +{"type":"user/message","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`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:11}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[9],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_filtered_write","name":"write","arguments":"{\"file_path\":\"filtered-write.txt\",\"content\":\"must not be written\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1789625685897,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1789625685897,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_filtered_write","name":"write","arguments":"{\"file_path\":\"filtered-write.txt\",\"content\":\"must not be written\"}"}}},{"type":"chunk","time":1789625685897,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1789625685897,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_filtered_write","name":"write","arguments":"{\"file_path\":\"filtered-write.txt\",\"content\":\"must not be written\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_filtered_write"},"toolCallId":"call_filtered_write","content":[{"type":"text","text":"Error: unknown tool \"write\""}],"isError":true,"id":"{{message:13}}"},"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:14}}"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17},"stream":[{"type":"chunk","time":1789625685920,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789625685920,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else","."]},{"type":"chunk","time":1789625685920,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789625685920,"index":1,"dt":[0,0],"texts":["CH","ILD","_OK"]},{"type":"chunk","time":1789625685920,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."}}},{"type":"chunk","time":1789625685920,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}},{"type":"chunk","time":1789625685920,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}}},{"type":"chunk","time":1789625685920,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/subagent-no-message-tool/session.v4.jsonl b/snapshots/session/subagent-no-message-tool/session.v4.jsonl new file mode 100644 index 0000000000..494db6c32c --- /dev/null +++ b/snapshots/session/subagent-no-message-tool/session.v4.jsonl @@ -0,0 +1,31 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783352119267,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","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":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Use the subagent tool exactly","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once to delegate the task: \"Reply with exactly the word CHILD_OK and nothing else.\"\n2. After the subagent returns, reply with the single word PARENT_DONE and stop.\n3. Do not use the bash tool.\n\nLet me do this."},{"type":"tool-call","id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":2907,"outputTokens":142,"cacheReadTokens":0,"reasoningTokens":67},"stream":[{"type":"chunk","time":1789625685850,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789625685851,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Use"," the"," sub","agent"," tool"," exactly"," once"," to"," delegate"," the"," task",":"," \"","Reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else",".\"\n","2","."," After"," the"," sub","agent"," returns",","," reply"," with"," the"," single"," word"," PAR","ENT","_D","ONE"," and"," stop",".\n","3","."," Do"," not"," use"," the"," bash"," tool",".\n\n","Let"," me"," do"," this","."]},{"type":"chunk","time":1789625685852,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789625685852,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","args":["","{","\"","description","\"",": ","\"","Reply"," with"," CH","ILD","_OK","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else",".","\"",", \"run_in_background\":false}"]},{"type":"chunk","time":1789625685852,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once to delegate the task: \"Reply with exactly the word CHILD_OK and nothing else.\"\n2. After the subagent returns, reply with the single word PARENT_DONE and stop.\n3. Do not use the bash tool.\n\nLet me do this."}}},{"type":"chunk","time":1789625685852,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\":false}"}}},{"type":"chunk","time":1789625685852,"chunk":{"type":"usage","usage":{"inputTokens":2907,"outputTokens":142,"cacheReadTokens":0,"reasoningTokens":67}}},{"type":"chunk","time":1789625685852,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\":false}"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{session:2}}","childCreatedAt":1789625685858,"mode":"continuable","label":"Reply with CHILD_OK"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_gVbLWC12Qu8JheZpVRRz8749"},"toolCallId":"call_00_gVbLWC12Qu8JheZpVRRz8749","content":[{"type":"text","text":"started subagent {{session:2}}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."},{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":120,"outputTokens":35,"cacheReadTokens":2944,"reasoningTokens":30},"stream":[{"type":"chunk","time":1789625685894,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789625685894,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," sub","agent"," returned"," \"","CH","ILD","_OK","\""," as"," expected","."," Now"," I"," need"," to"," reply"," with"," the"," single"," word"," \"","PAR","ENT","_D","ONE","\""," and"," stop","."]},{"type":"chunk","time":1789625685894,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789625685894,"index":1,"dt":[0,0,0],"texts":["PAR","ENT","_D","ONE"]},{"type":"chunk","time":1789625685894,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."}}},{"type":"chunk","time":1789625685894,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}},{"type":"chunk","time":1789625685894,"chunk":{"type":"usage","usage":{"inputTokens":120,"outputTokens":35,"cacheReadTokens":2944,"reasoningTokens":30}}},{"type":"chunk","time":1789625685894,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:7}}"}]}} +{"type":"turn/start","data":{"turn":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":2,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":4},"stream":[{"type":"chunk","time":1789625685939,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1789625685939,"index":0,"dt":[],"texts":["PARENT_DONE"]},{"type":"chunk","time":1789625685939,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_DONE"}}},{"type":"chunk","time":1789625685939,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}},{"type":"chunk","time":1789625685939,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":2,"step":1}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/subagent-no-message-tool/snapshot.yml b/snapshots/session/subagent-no-message-tool/snapshot.yml new file mode 100644 index 0000000000..832379c3f2 --- /dev/null +++ b/snapshots/session/subagent-no-message-tool/snapshot.yml @@ -0,0 +1,12 @@ +version: 1 +scenario: subagent-no-message-tool +profile: headless +composition: subagent-no-message-tool +recording: authored +header: + class: subagent-no-message-tool + pin: true + systemPromptSource: text-turn + toolSchemasSource: text-turn + childSystemPrompts: [1] + childToolSchemas: [1] diff --git a/snapshots/session/subagent-no-message-tool/system-prompt.1.expected.md b/snapshots/session/subagent-no-message-tool/system-prompt.1.expected.md new file mode 100644 index 0000000000..9f6132a881 --- /dev/null +++ b/snapshots/session/subagent-no-message-tool/system-prompt.1.expected.md @@ -0,0 +1,18 @@ +You are an AI agent powered by DeepSeek Harness. + +You are a coding assistant powered by the deepseek-v4-flash 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. + + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Use 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. + +Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use 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. + +Start independent delegations with `subagent` or `subagent_fork` together in one assistant message and continue useful work while they run. diff --git a/snapshots/session/subagent-no-message-tool/tool-schemas.1.expected.json b/snapshots/session/subagent-no-message-tool/tool-schemas.1.expected.json new file mode 100644 index 0000000000..984390733c --- /dev/null +++ b/snapshots/session/subagent-no-message-tool/tool-schemas.1.expected.json @@ -0,0 +1,435 @@ +{ + "initial": [ + { + "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 job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "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 job id immediately (collect with job_output, stop with job_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "exit_plan_mode", + "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", + "parameters": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "The complete plan, as markdown, starting with a # heading that names it." + } + }, + "required": [ + "plan" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. For local agents, only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. External executions stop permanently and cannot receive follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "job_kill", + "description": "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job 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 job." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "job_list", + "description": "List your background jobs (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "job_output", + "description": "Read a background job. Stream jobs return only output since the previous read; final-output jobs 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": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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": [ + "job_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "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": "read_image", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool starts an independently managed subagent and immediately returns its id. The runtime notifies you when it finishes. The child reports results with `send_message`; use `send_message` to steer it while running or continue its conversation after it finishes.", + "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." + } + }, + "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). 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 its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its id. The runtime notifies you when it finishes. The child reports results with `send_message`; use `send_message` to steer it while running or continue its conversation after it finishes.", + "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." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "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" + ] + } + } + ], + "changes": [] +} From 1e00ab89a715c850c3057c8b99227e097e2e666a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 20 Sep 2026 17:43:31 +0800 Subject: [PATCH 021/402] refactor(subagent): keep catalog entries as creation facts --- ...-17-unified-subagent-activations.i18n.yaml | 4 +- ...2026-09-17-unified-subagent-activations.md | 8 +- ...6-09-17-unified-subagent-activations.zh.md | 8 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 77 +- docs/persistence-catalog.zh.md | 77 +- ...-09-19-external-subagent-catalog.i18n.yaml | 4 +- .../2026-09-19-external-subagent-catalog.md | 8 +- ...9-19-external-subagent-catalog.schema.json | 71 +- ...2026-09-19-external-subagent-catalog.zh.md | 8 +- .../historical-formats/README.i18n.yaml | 4 +- .../historical-formats/README.md | 2 +- .../historical-formats/README.zh.md | 2 +- docs/persistence-schema.json | 1468 +++++++---------- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 2 +- docs/subsystems/subagent.zh.md | 2 +- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 2 +- packages/client/ui-subagent/README.zh.md | 2 +- .../src/client/SubagentHeaderLineage.tsx | 4 +- .../client/ui-subagent/src/client/locales.ts | 12 - .../tests/conversation-ui.client.spec.tsx | 14 +- .../tests/workflow-run.client.spec.tsx | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 8 +- .../session-format-v3-to-v4/README.i18n.yaml | 4 +- .../session/session-format-v3-to-v4/README.md | 2 +- .../session-format-v3-to-v4/README.zh.md | 2 +- .../session-format-v3-to-v4/src/validation.ts | 13 +- .../tests/codec.spec.ts | 21 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/src/catalog.ts | 14 +- .../subagent/src/continuation-activation.ts | 10 - .../subagent/subagent/src/continuation.ts | 12 +- .../subagent/subagent/src/control-types.ts | 3 +- packages/subagent/subagent/src/index.ts | 2 +- .../subagent/subagent/src/projection-types.ts | 5 +- .../subagent/subagent/tests/catalog.spec.ts | 15 +- .../tests/external-activation.spec.ts | 39 +- .../tests/list-agents.spec.ts | 25 +- 42 files changed, 795 insertions(+), 1181 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.i18n.yaml index 07734a8954..b94acc03f3 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.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-09-17-unified-subagent-activations.md -2026-09-17-unified-subagent-activations.md: 07701562fb8804717e4c9ab43825ffa773266e04 -2026-09-17-unified-subagent-activations.zh.md: 1d98d11e063f5ef92c08e558c2646a15f0cbc3ac +2026-09-17-unified-subagent-activations.md: ae663e7eab0673f6f6d7a43cd5ca4656e3f757e1 +2026-09-17-unified-subagent-activations.zh.md: 2a2a243f6c13e13bd4f9b0b59bcf5d4218077c07 diff --git a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md index 07701562fb..ae663e7eab 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md +++ b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md @@ -14,13 +14,13 @@ Local one-shot execution and continuable residency separately owned child creati Local spawn and fork providers contribute only `prepareContinuable()` data; the subagent service owns Agent creation and residency. The standalone in-process driver package is removed. Structured tools, validation, instructions, and terminal guards attach to one activation and close with it. After capture, that activation rejects further input; cold resume reconstructs an ordinary conversation without the previous schema. -ACP, DSH SDK, Codex, and Claude Code retain their existing single-execution provider adapters. They join the same activation capacity and ownership graph without accepting follow-up messages or fabricating a local child Session. The parent-owned catalog registers both local children and external leaves. External entries carry an optional last-recorded outcome; pending means no terminal outcome is recorded and makes no claim about process liveness. Settlement updates the same entry without storing or broadcasting full output. Caller delivery returns the complete result; parent delivery queues the complete completion notice. +ACP, DSH SDK, Codex, and Claude Code retain their existing single-execution provider adapters. They join the same activation capacity and ownership graph without accepting follow-up messages or fabricating a local child Session. The parent-owned catalog registers both local children and external leaves. External entries carry `external: true` because they have no local Session to open. Creation records membership once; execution and cleanup do not update the catalog. Execution status has no required consumer and remains outside the membership record. Caller delivery returns the complete result; parent delivery queues the complete completion notice. The model-facing tool always returns a background child id and promises the manager's completion notice. It has no foreground switch or Job integration. Workflows select caller delivery, await the activation result, and dispose the handle before finishing; they add neither a completion notice nor initial return guidance to the parent/child exchange. Headless completion waits for its own child tree and subsequent parent turns. ### Retained decisions -The native Session reader permits one pending-to-terminal update for an external catalog entry only when its mode, creation time, and label remain unchanged. Local duplicate membership and repeated terminal updates still reject restoration. +The native Session reader rejects duplicate child membership for both local and external catalog entries. The [named-provider seam](../feature/2026-06-21-subagent-capability-seam.md), [continuable residency](../feature/2026-07-28-continuable-subagent-conversations.md), [fork request prefix](../architecture/2026-08-10-fork-children-stay-one-shot.md), [settlement delivery ordering](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.md), [parent-owned catalog](../architecture/2026-09-01-parent-owned-subagent-catalog.md), and [activation capacity](../feature/2026-09-15-continuable-activation-capacity.md) retain their independent rationale. This decision owns the shared entry point, external participation, and caller-versus-parent result delivery. Those records are partially superseded, not candidates for consolidation or archival. @@ -28,7 +28,7 @@ Released Session generations remain immutable. Historical one-shot descriptors s ## Alternatives considered -**Keep a separate external execution projection.** It duplicates membership, restoration, and client update handling already supplied by the parent catalog. Repeated directory snapshots would also retransmit every retained result. The catalog retains only identity and outcome; results remain with their recipient. +**Keep a separate external execution projection.** It duplicates membership, restoration, and client update handling already supplied by the parent catalog. Repeated directory snapshots would also retransmit every retained result. The catalog retains only membership; results remain with their recipient. **Keep one-shot local execution for synchronous workflows.** Awaiting a result is a consumer requirement. A second Agent lifecycle duplicates cancellation and cleanup solely to supply a promise, which the activation can supply directly. @@ -44,4 +44,4 @@ One manager controls admission, cancellation, ownership, and resource release ac ## Verification -Focused tests cover local spawn/fork inheritance, structured capture and schema-free cold resume, explicit cancellation, external catalog updates, result-versus-disposal ordering, workflow collection without parent notices, and headless child-tree completion. Existing replay generations are preserved; updated recorded-session cases cover the current model-visible tool and notice behavior. +Focused tests cover local spawn/fork inheritance, structured capture and schema-free cold resume, explicit cancellation, external catalog membership, result-versus-disposal ordering, workflow collection without parent notices, and headless child-tree completion. Existing replay generations are preserved; updated recorded-session cases cover the current model-visible tool and notice behavior. diff --git a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md index 1d98d11e06..2a2a243f6c 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md @@ -14,13 +14,13 @@ Status: implemented 本地 spawn 和 fork 提供方只贡献 `prepareContinuable()` 数据,由 subagent 服务拥有 Agent 创建与驻留。独立进程内驱动器包被移除。结构化工具、校验、指令与终止保护附着于一次 activation,并随其关闭。捕获后该 activation 拒绝继续输入;冷恢复重建普通对话,不携带此前的 schema。 -ACP、DSH SDK、Codex 和 Claude Code 保留单次执行适配器,共享 activation 容量与所有权,不接受后续消息,也不伪造本地子 Session。父级拥有的 catalog 同时登记本地子代理与外部叶子项。外部条目携带可选的最近记录结果状态;pending 表示尚无终态记录,不声明进程是否存活。结束时更新同一条目,不存储或广播完整输出。caller 交付返回完整结果,parent 交付将完整完成通知加入父级队列。 +ACP、DSH SDK、Codex 和 Claude Code 保留单次执行适配器,共享 activation 容量与所有权,不接受后续消息,也不伪造本地子 Session。父级拥有的 catalog 同时登记本地子代理与外部叶子项。外部条目携带 `external: true`,因为没有可打开的本地 Session。创建时记录一次成员关系;执行与清理不更新目录。执行状态没有必需的消费者,不属于成员记录。caller 交付返回完整结果,parent 交付将完整完成通知加入父级队列。 面向模型的工具始终返回后台子级 ID,并承诺管理器的完成通知。它没有前台开关或 Job 集成。工作流选择调用方投递,等待 activation 结果并在完成前释放句柄;它们不向父子交互添加完成通知或初始返回指导。Headless 完成流程等待其自身子树与后续父级轮次。 ### 保留的决策 -原生 Session 读取器仅在模式、创建时间与标签不变时,允许外部目录条目从 pending 更新到终态一次。本地重复成员记录与重复终态更新仍会拒绝恢复。 +原生 Session 读取器对本地与外部目录条目都拒绝重复的子级成员记录。 [具名提供方 seam](../feature/2026-06-21-subagent-capability-seam.zh.md)、[可继续驻留](../feature/2026-07-28-continuable-subagent-conversations.zh.md)、[fork 请求前缀](../architecture/2026-08-10-fork-children-stay-one-shot.zh.md)、[结算投递顺序](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md)、[父级拥有的目录](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 和 [activation 容量](../feature/2026-09-15-continuable-activation-capacity.zh.md) 保留各自独立理由。本决策拥有共享入口、外部执行参与以及调用方与父级之间的结果投递选择。这些记录仅被部分取代,不满足合并或归档条件。 @@ -28,7 +28,7 @@ ACP、DSH SDK、Codex 和 Claude Code 保留单次执行适配器,共享 activ ## 考虑过的替代方案 -**保留独立的外部执行投影。** 它重复父级 catalog 已提供的成员登记、恢复与客户端更新,每次目录快照还会重传全部结果。catalog 仅保留身份和结果状态,完整结果由接收方负责。 +**保留独立的外部执行投影。** 它重复父级 catalog 已提供的成员登记、恢复与客户端更新,每次目录快照还会重传全部结果。catalog 仅保留成员关系,完整结果由接收方负责。 **为同步工作流保留本地一次性执行。** 等待结果是消费者需求。仅为提供 promise 而维护第二套 Agent 生命周期会重复取消与清理;activation 可以直接提供该 promise。 @@ -44,4 +44,4 @@ ACP、DSH SDK、Codex 和 Claude Code 保留单次执行适配器,共享 activ ## 验证 -聚焦测试覆盖本地 spawn/fork 继承、结构化捕获与无 schema 冷恢复、显式取消、外部目录更新、结果与释放顺序、不发父级通知的工作流收集,以及 headless 子树完成。既有回放世代得到保留;更新的录制会话用例覆盖当前面向模型的工具与通知行为。 +聚焦测试覆盖本地 spawn/fork 继承、结构化捕获与无 schema 冷恢复、显式取消、外部目录成员关系、结果与释放顺序、不发父级通知的工作流收集,以及 headless 子树完成。既有回放世代得到保留;更新的录制会话用例覆盖当前面向模型的工具与通知行为。 diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 3890f7edf9..8dc9332e84 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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/persistence-catalog.md -persistence-catalog.md: da664a192c53db44f3a08a461c6f21411a319e7a -persistence-catalog.zh.md: 03d8d52b0d6dc2c0b00872b1d2fd9cbac5add652 +persistence-catalog.md: c7212b543a367e27d7816cfbc5e3aa51a058b0be +persistence-catalog.zh.md: 77b36cf26cf73c29bcaa702575a7101b25d7202f diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index da664a192c..c7212b543a 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -57,7 +57,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376) | +| `event:subagent/catalog` | event | `568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -2219,14 +2219,6 @@ SHA-256: `383cf73ae4748e5beb281ae74857ba1ffb92ae01075b9fbf51af78b666007a8f` `"recall"` - - -### `"refusal"` - -SHA-256: `4a561c111a78d4520e850c93795369ea169ff86abd082aee86c8fd38121a65aa` - -`"refusal"` - ### `"rejected"` @@ -4563,7 +4555,7 @@ One of: - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4571,17 +4563,17 @@ One of: ### `SubagentCatalogEvent` -SHA-256: `c974738c089d11324eac001a8e4c032d278cebc0221efb9896ff72b922d82b7e` +SHA-256: `4e6c80858802af53690bae54e01a4e416c7aec5b34a8817b21d6cf33a9603b52` Sources: [`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) One of: -- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-bbf8af8c6b0edb0b150ea997c51bdc63e8def8428adb228bbc88af0577344391) -- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-0300cd374b1d3d9843f1a5f523c3c8fc920d2bc04be32380331359ac1edb1dbe) -- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) +- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-7e25366aed8235508ef657343f0f263f3354b8418406dcd857acecd7e1c74cd0) +- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-718c94fb5c5ac43cdd163d384bd75b3ca8bc355a02727423cd42d97948263128) @@ -5459,21 +5451,6 @@ One of: - `"pause"` - `"resume"` - - -### `union (6 variants)` - -SHA-256: `ae2a48f2f3e53adc041eb3d7ee98a4880af2f81484c20c1d07a0f80dbfb7c0c1` - -One of: - -- `"aborted"` -- `"completed"` -- `"error"` -- `"max-tokens"` -- `"pending"` -- `"refusal"` - ### `union (7 variants)` @@ -5632,36 +5609,36 @@ SHA-256: `df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18` Array of [`{ capturedFormatVersion?, capturedThroughSeq, compacted, inputIndex, … }`](#persistence-type-sha256-4f91f187baeabef5c7950c4780d31bbe39f3345dff591a6cf26ac7e135ac1cf4). - + ### `{ childCreatedAt, childId, external?, label?, … }` -SHA-256: `0300cd374b1d3d9843f1a5f523c3c8fc920d2bc04be32380331359ac1edb1dbe` +SHA-256: `718c94fb5c5ac43cdd163d384bd75b3ca8bc355a02727423cd42d97948263128` | Property | Presence | Type | |---|---|---| | `childCreatedAt` | required | `number` | | `childId` | required | `string` | -| `external` | optional | [`union (6 variants)`](#persistence-type-sha256-ae2a48f2f3e53adc041eb3d7ee98a4880af2f81484c20c1d07a0f80dbfb7c0c1) | -| `label` | optional | `string` | -| `mode` | required | `"one-shot"` | -| `version` | required | `0` | - - - -### `{ childCreatedAt, childId, external?, label?, … }` - -SHA-256: `bbf8af8c6b0edb0b150ea997c51bdc63e8def8428adb228bbc88af0577344391` - -| Property | Presence | Type | -|---|---|---| -| `childCreatedAt` | required | `number` | -| `childId` | required | `string` | -| `external` | optional | [`union (6 variants)`](#persistence-type-sha256-ae2a48f2f3e53adc041eb3d7ee98a4880af2f81484c20c1d07a0f80dbfb7c0c1) | +| `external` | optional | `true` | | `label` | optional | `string` | | `mode` | required | `"one-shot"` | | `version` | required | `1` | + + +### `{ childCreatedAt, childId, external?, label?, … }` + +SHA-256: `7e25366aed8235508ef657343f0f263f3354b8418406dcd857acecd7e1c74cd0` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `external` | optional | `true` | +| `label` | optional | `string` | +| `mode` | required | `"one-shot"` | +| `version` | required | `0` | + ### `{ childCreatedAt, childId, label, mode, … }` @@ -8299,17 +8276,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | required | `number` | | `type` | required | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376` +SHA-256: `568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-c974738c089d11324eac001a8e4c032d278cebc0221efb9896ff72b922d82b7e) | +| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-4e6c80858802af53690bae54e01a4e416c7aec5b34a8817b21d6cf33a9603b52) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 03d8d52b0d..77b36cf26c 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -59,7 +59,7 @@ | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376) | +| `event:subagent/catalog` | event | `568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -2221,14 +2221,6 @@ SHA-256: `383cf73ae4748e5beb281ae74857ba1ffb92ae01075b9fbf51af78b666007a8f` `"recall"` - - -### `"refusal"` - -SHA-256: `4a561c111a78d4520e850c93795369ea169ff86abd082aee86c8fd38121a65aa` - -`"refusal"` - ### `"rejected"` @@ -4565,7 +4557,7 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4573,17 +4565,17 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` ### `SubagentCatalogEvent` -SHA-256: `c974738c089d11324eac001a8e4c032d278cebc0221efb9896ff72b922d82b7e` +SHA-256: `4e6c80858802af53690bae54e01a4e416c7aec5b34a8817b21d6cf33a9603b52` 来源:[`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) 以下类型之一: -- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-bbf8af8c6b0edb0b150ea997c51bdc63e8def8428adb228bbc88af0577344391) -- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-0300cd374b1d3d9843f1a5f523c3c8fc920d2bc04be32380331359ac1edb1dbe) -- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) +- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-7e25366aed8235508ef657343f0f263f3354b8418406dcd857acecd7e1c74cd0) +- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-718c94fb5c5ac43cdd163d384bd75b3ca8bc355a02727423cd42d97948263128) @@ -5461,21 +5453,6 @@ SHA-256: `62e6a429ff3f390a46ac39fb468692032f42936ca0db19b53454184189401379` - `"pause"` - `"resume"` - - -### `union (6 variants)` - -SHA-256: `ae2a48f2f3e53adc041eb3d7ee98a4880af2f81484c20c1d07a0f80dbfb7c0c1` - -以下类型之一: - -- `"aborted"` -- `"completed"` -- `"error"` -- `"max-tokens"` -- `"pending"` -- `"refusal"` - ### `union (7 variants)` @@ -5634,36 +5611,36 @@ SHA-256: `df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18` [`{ capturedFormatVersion?, capturedThroughSeq, compacted, inputIndex, … }`](#persistence-type-sha256-4f91f187baeabef5c7950c4780d31bbe39f3345dff591a6cf26ac7e135ac1cf4) 的数组。 - + ### `{ childCreatedAt, childId, external?, label?, … }` -SHA-256: `0300cd374b1d3d9843f1a5f523c3c8fc920d2bc04be32380331359ac1edb1dbe` +SHA-256: `718c94fb5c5ac43cdd163d384bd75b3ca8bc355a02727423cd42d97948263128` | 属性 | 存在性 | 类型 | |---|---|---| | `childCreatedAt` | 必需 | `number` | | `childId` | 必需 | `string` | -| `external` | 可选 | [`union (6 variants)`](#persistence-type-sha256-ae2a48f2f3e53adc041eb3d7ee98a4880af2f81484c20c1d07a0f80dbfb7c0c1) | -| `label` | 可选 | `string` | -| `mode` | 必需 | `"one-shot"` | -| `version` | 必需 | `0` | - - - -### `{ childCreatedAt, childId, external?, label?, … }` - -SHA-256: `bbf8af8c6b0edb0b150ea997c51bdc63e8def8428adb228bbc88af0577344391` - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `childCreatedAt` | 必需 | `number` | -| `childId` | 必需 | `string` | -| `external` | 可选 | [`union (6 variants)`](#persistence-type-sha256-ae2a48f2f3e53adc041eb3d7ee98a4880af2f81484c20c1d07a0f80dbfb7c0c1) | +| `external` | 可选 | `true` | | `label` | 可选 | `string` | | `mode` | 必需 | `"one-shot"` | | `version` | 必需 | `1` | + + +### `{ childCreatedAt, childId, external?, label?, … }` + +SHA-256: `7e25366aed8235508ef657343f0f263f3354b8418406dcd857acecd7e1c74cd0` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `external` | 可选 | `true` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"one-shot"` | +| `version` | 必需 | `0` | + ### `{ childCreatedAt, childId, label, mode, … }` @@ -8301,17 +8278,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | 必需 | `number` | | `type` | 必需 | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376` +SHA-256: `568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-c974738c089d11324eac001a8e4c032d278cebc0221efb9896ff72b922d82b7e) | +| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-4e6c80858802af53690bae54e01a4e416c7aec5b34a8817b21d6cf33a9603b52) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | diff --git a/docs/persistence-changes/2026-09-19-external-subagent-catalog.i18n.yaml b/docs/persistence-changes/2026-09-19-external-subagent-catalog.i18n.yaml index e9884f53db..2a9ff18bf9 100644 --- a/docs/persistence-changes/2026-09-19-external-subagent-catalog.i18n.yaml +++ b/docs/persistence-changes/2026-09-19-external-subagent-catalog.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/persistence-changes/2026-09-19-external-subagent-catalog.md -2026-09-19-external-subagent-catalog.md: 6c5321567c8479ff7432f411833d5549dd7259fc -2026-09-19-external-subagent-catalog.zh.md: 6d51ab84c386ff07129569433991980851e2dd7e +2026-09-19-external-subagent-catalog.md: 3ca24aec51d7aaaf1cb7421ab90032df4f5d8b20 +2026-09-19-external-subagent-catalog.zh.md: 34f0d4a08722376c1d5223adbbac71ed06f211c1 diff --git a/docs/persistence-changes/2026-09-19-external-subagent-catalog.md b/docs/persistence-changes/2026-09-19-external-subagent-catalog.md index 6c5321567c..3ca24aec51 100644 --- a/docs/persistence-changes/2026-09-19-external-subagent-catalog.md +++ b/docs/persistence-changes/2026-09-19-external-subagent-catalog.md @@ -9,7 +9,7 @@ English | [中文](2026-09-19-external-subagent-catalog.zh.md) ## Summary -Add an optional external execution outcome to one-shot parent catalog entries. +Add an optional external membership marker to one-shot parent catalog entries. ## Table of Contents @@ -28,19 +28,19 @@ baseline: false changes: - root: "event:subagent/catalog" previous: "2026-09-20-unknown-child-catalog" - after: "289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376" + after: "568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada" decision: same-version ``` ## Compatibility -Existing version-0 and version-1 one-shot and continuable catalog facts remain valid without the optional external field. Version-1 unknown-mode facts remain unchanged. The field identifies a child without a local Session and records pending or a terminal outcome. Pending means no terminal outcome is recorded, not that a process is live. Settlement appends a complete catalog fact with the same identity and creation time; the projection keeps one row per child. No new Session event type, full result payload, or Session format version is introduced. +Existing version-0 and version-1 catalog facts remain valid without the optional external field. The literal true identifies a child without a local Session. Creation writes one membership fact; execution and cleanup do not update it. Duplicate child ids remain invalid. Unknown-mode and continuable entries do not accept the marker. This updates the unaccepted optional-field addition in this PR and does not change released generations or the Session format version. ## Verification -Catalog projection, V3-to-V4 catalog completion and validation, and JSONL catalog migration tests passed: 124 tests passed and one skipped. Coverage includes mixed unknown-mode and external entries at both supported payload versions, external pending-to-terminal updates, and rejection of external fields on unknown or continuable entries. +Focused catalog, external activation, native V4 restoration, and client tests passed. They cover creation-time membership, no settlement update, cleanup failure independent of catalog membership, and duplicate-child rejection. ## Dev Note diff --git a/docs/persistence-changes/2026-09-19-external-subagent-catalog.schema.json b/docs/persistence-changes/2026-09-19-external-subagent-catalog.schema.json index 27682957da..acdecbf08c 100644 --- a/docs/persistence-changes/2026-09-19-external-subagent-catalog.schema.json +++ b/docs/persistence-changes/2026-09-19-external-subagent-catalog.schema.json @@ -6,7 +6,7 @@ "kind": "event", "event": "subagent/catalog", "surface": false, - "digest": "289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376", + "digest": "568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada", "schema": { "root": 0, "nodes": [ @@ -20,7 +20,7 @@ }, { "name": "ignorable", - "type": 21, + "type": 5, "optional": true }, { @@ -35,7 +35,7 @@ }, { "name": "type", - "type": 22, + "type": 15, "optional": false } ], @@ -45,10 +45,10 @@ "kind": "union", "types": [ 2, - 14, - 16, - 18, - 19 + 8, + 10, + 12, + 13 ] }, { @@ -76,12 +76,12 @@ }, { "name": "mode", - "type": 12, + "type": 6, "optional": false }, { "name": "version", - "type": 13, + "type": 7, "optional": false } ], @@ -95,40 +95,9 @@ "kind": "primitive", "type": "string" }, - { - "kind": "union", - "types": [ - 6, - 7, - 8, - 9, - 10, - 11 - ] - }, { "kind": "literal", - "value": "aborted" - }, - { - "kind": "literal", - "value": "completed" - }, - { - "kind": "literal", - "value": "error" - }, - { - "kind": "literal", - "value": "max-tokens" - }, - { - "kind": "literal", - "value": "pending" - }, - { - "kind": "literal", - "value": "refusal" + "value": true }, { "kind": "literal", @@ -163,12 +132,12 @@ }, { "name": "mode", - "type": 12, + "type": 6, "optional": false }, { "name": "version", - "type": 15, + "type": 9, "optional": false } ], @@ -198,12 +167,12 @@ }, { "name": "mode", - "type": 17, + "type": 11, "optional": false }, { "name": "version", - "type": 13, + "type": 7, "optional": false } ], @@ -233,12 +202,12 @@ }, { "name": "mode", - "type": 17, + "type": 11, "optional": false }, { "name": "version", - "type": 15, + "type": 9, "optional": false } ], @@ -264,12 +233,12 @@ }, { "name": "mode", - "type": 20, + "type": 14, "optional": false }, { "name": "version", - "type": 15, + "type": 9, "optional": false } ], @@ -279,10 +248,6 @@ "kind": "literal", "value": "unknown" }, - { - "kind": "literal", - "value": true - }, { "kind": "literal", "value": "subagent/catalog" diff --git a/docs/persistence-changes/2026-09-19-external-subagent-catalog.zh.md b/docs/persistence-changes/2026-09-19-external-subagent-catalog.zh.md index 6d51ab84c3..34f0d4a087 100644 --- a/docs/persistence-changes/2026-09-19-external-subagent-catalog.zh.md +++ b/docs/persistence-changes/2026-09-19-external-subagent-catalog.zh.md @@ -9,7 +9,7 @@ kind: persistence-change ## 概述 -为父级目录中的一次性条目添加可选的外部执行结果。 +为一次性父级目录条目增加可选的外部成员标记。 ## 目录 @@ -28,19 +28,19 @@ baseline: false changes: - root: "event:subagent/catalog" previous: "2026-09-20-unknown-child-catalog" - after: "289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376" + after: "568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada" decision: same-version ``` ## 兼容性 -已有 version-0 和 version-1 的一次性及可继续目录事实在没有可选 external 字段时仍然有效。Version-1 的未知模式事实保持不变。该字段标识没有本地 Session 的子任务,并记录 pending 或终态。Pending 表示尚未记录终态,不代表进程存活。结束时追加具有相同身份与创建时间的完整目录事实;投影为每个子任务保留一行。不引入新的 Session 事件类型、完整结果载荷或 Session 格式版本。 +已有 version-0 与 version-1 目录事实无需 external 字段仍然有效。字面量 true 标记没有本地 Session 的子级。创建时写入一次成员事实,执行与清理均不更新该记录。重复子 id 仍然无效。未知模式与可继续条目不接受此标记。本次更新本 PR 尚未接受的可选字段新增记录,不改变已发布世代或 Session 格式版本。 ## 验证 -目录投影、V3 到 V4 目录补全与校验、JSONL 目录迁移测试通过:124 项通过,1 项跳过。覆盖两种受支持载荷版本中混合的未知模式和外部条目、外部 pending 到终态的更新,以及拒绝未知模式或可继续条目上的 external 字段。 +聚焦的目录、外部 activation、原生 V4 恢复与客户端测试通过,覆盖创建时成员登记、不发布结算更新、清理失败独立于目录成员关系以及拒绝重复子级。 ## 开发备注 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 62def62200..1f6bfd547a 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/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 docs/persistence-changes/historical-formats/README.md -README.md: 453137b6f196b5572973d793f6648220b2366571 -README.zh.md: a64ce4f1e030f35b831afc794625c0ec79ab394b +README.md: a1064e815879d709fe466441bb4e8441c10b3aae +README.zh.md: 87c9d32fa6ca5248886dad663a926afc6983406f diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 453137b6f1..a1064e8158 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 589 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 587 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index a64ce4f1e0..87c9d32fa6 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 589 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 587 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 30205cf72a..fc82d0c3f4 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -14850,7 +14850,7 @@ "kind": "event", "event": "subagent/catalog", "surface": false, - "digest": "289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376", + "digest": "568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada", "schema": { "root": 0, "nodes": [ @@ -14864,7 +14864,7 @@ }, { "name": "ignorable", - "type": 21, + "type": 5, "optional": true }, { @@ -14879,7 +14879,7 @@ }, { "name": "type", - "type": 22, + "type": 15, "optional": false } ], @@ -14889,10 +14889,10 @@ "kind": "union", "types": [ 2, - 14, - 16, - 18, - 19 + 8, + 10, + 12, + 13 ] }, { @@ -14920,12 +14920,12 @@ }, { "name": "mode", - "type": 12, + "type": 6, "optional": false }, { "name": "version", - "type": 13, + "type": 7, "optional": false } ], @@ -14939,40 +14939,9 @@ "kind": "primitive", "type": "string" }, - { - "kind": "union", - "types": [ - 6, - 7, - 8, - 9, - 10, - 11 - ] - }, { "kind": "literal", - "value": "aborted" - }, - { - "kind": "literal", - "value": "completed" - }, - { - "kind": "literal", - "value": "error" - }, - { - "kind": "literal", - "value": "max-tokens" - }, - { - "kind": "literal", - "value": "pending" - }, - { - "kind": "literal", - "value": "refusal" + "value": true }, { "kind": "literal", @@ -15007,12 +14976,12 @@ }, { "name": "mode", - "type": 12, + "type": 6, "optional": false }, { "name": "version", - "type": 15, + "type": 9, "optional": false } ], @@ -15042,12 +15011,12 @@ }, { "name": "mode", - "type": 17, + "type": 11, "optional": false }, { "name": "version", - "type": 13, + "type": 7, "optional": false } ], @@ -15077,12 +15046,12 @@ }, { "name": "mode", - "type": 17, + "type": 11, "optional": false }, { "name": "version", - "type": 15, + "type": 9, "optional": false } ], @@ -15108,12 +15077,12 @@ }, { "name": "mode", - "type": 20, + "type": 14, "optional": false }, { "name": "version", - "type": 15, + "type": 9, "optional": false } ], @@ -15123,10 +15092,6 @@ "kind": "literal", "value": "unknown" }, - { - "kind": "literal", - "value": true - }, { "kind": "literal", "value": "subagent/catalog" @@ -21826,103 +21791,6 @@ "packages/extensions/cordis-host-runner/src/index.ts:14" ] }, - { - "digest": "0300cd374b1d3d9843f1a5f523c3c8fc920d2bc04be32380331359ac1edb1dbe", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 1, - "optional": false - }, - { - "name": "childId", - "type": 2, - "optional": false - }, - { - "name": "external", - "type": 3, - "optional": true - }, - { - "name": "label", - "type": 2, - "optional": true - }, - { - "name": "mode", - "type": 10, - "optional": false - }, - { - "name": "version", - "type": 11, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "union", - "types": [ - 4, - 5, - 6, - 7, - 8, - 9 - ] - }, - { - "kind": "literal", - "value": "aborted" - }, - { - "kind": "literal", - "value": "completed" - }, - { - "kind": "literal", - "value": "error" - }, - { - "kind": "literal", - "value": "max-tokens" - }, - { - "kind": "literal", - "value": "pending" - }, - { - "kind": "literal", - "value": "refusal" - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": 0 - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "0341d4c3dfcd53253cdc1e789d9130d3cd848b6f8b0706bf92f313a68b39ff23", "schema": { @@ -32712,293 +32580,6 @@ "names": [], "sources": [] }, - { - "digest": "289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 21, - "optional": true - }, - { - "name": "seq", - "type": 3, - "optional": false - }, - { - "name": "time", - "type": 3, - "optional": false - }, - { - "name": "type", - "type": 22, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "union", - "types": [ - 2, - 14, - 16, - 18, - 19 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "external", - "type": 5, - "optional": true - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 12, - "optional": false - }, - { - "name": "version", - "type": 13, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "union", - "types": [ - 6, - 7, - 8, - 9, - 10, - 11 - ] - }, - { - "kind": "literal", - "value": "aborted" - }, - { - "kind": "literal", - "value": "completed" - }, - { - "kind": "literal", - "value": "error" - }, - { - "kind": "literal", - "value": "max-tokens" - }, - { - "kind": "literal", - "value": "pending" - }, - { - "kind": "literal", - "value": "refusal" - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "external", - "type": 5, - "optional": true - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 12, - "optional": false - }, - { - "name": "version", - "type": 15, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": 1 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": false - }, - { - "name": "mode", - "type": 17, - "optional": false - }, - { - "name": "version", - "type": 13, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": false - }, - { - "name": "mode", - "type": 17, - "optional": false - }, - { - "name": "version", - "type": 15, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 20, - "optional": false - }, - { - "name": "version", - "type": 15, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "unknown" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "subagent/catalog" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "290f32bfd81ff85a8095886a8066fae06e885942b63a8bcd6a8d66d328445808", "schema": { @@ -43987,20 +43568,6 @@ "names": [], "sources": [] }, - { - "digest": "4a561c111a78d4520e850c93795369ea169ff86abd082aee86c8fd38121a65aa", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "literal", - "value": "refusal" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "4a9bef9f27a942520cc2279ca7dd3082556e4bff543f66b5c0333e1bbed1cd86", "schema": { @@ -44655,6 +44222,227 @@ "names": [], "sources": [] }, + { + "digest": "4e6c80858802af53690bae54e01a4e416c7aec5b34a8817b21d6cf33a9603b52", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "union", + "types": [ + 1, + 6, + 8, + 10, + 13 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 9, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "external", + "type": 11, + "optional": true + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 12, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "external", + "type": 11, + "optional": true + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 12, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + } + ] + }, + "names": [ + "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" + ], + "sources": [ + "packages/subagent/subagent/src/catalog.ts:28" + ] + }, { "digest": "4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c", "schema": { @@ -49784,6 +49572,258 @@ "names": [], "sources": [] }, + { + "digest": "568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 5, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 8, + 10, + 12, + 13 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "external", + "type": 5, + "optional": true + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 6, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "external", + "type": 5, + "optional": true + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 6, + "optional": false + }, + { + "name": "version", + "type": 9, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 11, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 11, + "optional": false + }, + { + "name": "version", + "type": 9, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 14, + "optional": false + }, + { + "name": "version", + "type": 9, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": "subagent/catalog" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23", "schema": { @@ -59103,6 +59143,72 @@ "names": [], "sources": [] }, + { + "digest": "718c94fb5c5ac43cdd163d384bd75b3ca8bc355a02727423cd42d97948263128", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "external", + "type": 3, + "optional": true + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91", "schema": { @@ -60871,6 +60977,72 @@ "names": [], "sources": [] }, + { + "digest": "7e25366aed8235508ef657343f0f263f3354b8418406dcd857acecd7e1c74cd0", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "external", + "type": 3, + "optional": true + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 0 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "7eaef7a763ba5b949b8464bc8add8980a3a5ab566014b69787aeba71e5dfe07b", "schema": { @@ -70623,51 +70795,6 @@ "names": [], "sources": [] }, - { - "digest": "ae2a48f2f3e53adc041eb3d7ee98a4880af2f81484c20c1d07a0f80dbfb7c0c1", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "union", - "types": [ - 1, - 2, - 3, - 4, - 5, - 6 - ] - }, - { - "kind": "literal", - "value": "aborted" - }, - { - "kind": "literal", - "value": "completed" - }, - { - "kind": "literal", - "value": "error" - }, - { - "kind": "literal", - "value": "max-tokens" - }, - { - "kind": "literal", - "value": "pending" - }, - { - "kind": "literal", - "value": "refusal" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "ae63d9d1080ed8945031594d93f8d96651d7101b848719cc3e1475875cd3d649", "schema": { @@ -73167,103 +73294,6 @@ "names": [], "sources": [] }, - { - "digest": "bbf8af8c6b0edb0b150ea997c51bdc63e8def8428adb228bbc88af0577344391", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 1, - "optional": false - }, - { - "name": "childId", - "type": 2, - "optional": false - }, - { - "name": "external", - "type": 3, - "optional": true - }, - { - "name": "label", - "type": 2, - "optional": true - }, - { - "name": "mode", - "type": 10, - "optional": false - }, - { - "name": "version", - "type": 11, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "union", - "types": [ - 4, - 5, - 6, - 7, - 8, - 9 - ] - }, - { - "kind": "literal", - "value": "aborted" - }, - { - "kind": "literal", - "value": "completed" - }, - { - "kind": "literal", - "value": "error" - }, - { - "kind": "literal", - "value": "max-tokens" - }, - { - "kind": "literal", - "value": "pending" - }, - { - "kind": "literal", - "value": "refusal" - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": 1 - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825", "schema": { @@ -73953,258 +73983,6 @@ "names": [], "sources": [] }, - { - "digest": "c974738c089d11324eac001a8e4c032d278cebc0221efb9896ff72b922d82b7e", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "union", - "types": [ - 1, - 13, - 15, - 17, - 18 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "external", - "type": 4, - "optional": true - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", - "type": 11, - "optional": false - }, - { - "name": "version", - "type": 12, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "union", - "types": [ - 5, - 6, - 7, - 8, - 9, - 10 - ] - }, - { - "kind": "literal", - "value": "aborted" - }, - { - "kind": "literal", - "value": "completed" - }, - { - "kind": "literal", - "value": "error" - }, - { - "kind": "literal", - "value": "max-tokens" - }, - { - "kind": "literal", - "value": "pending" - }, - { - "kind": "literal", - "value": "refusal" - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": 1 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "external", - "type": 4, - "optional": true - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", - "type": 11, - "optional": false - }, - { - "name": "version", - "type": 14, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": false - }, - { - "name": "mode", - "type": 16, - "optional": false - }, - { - "name": "version", - "type": 12, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": false - }, - { - "name": "mode", - "type": 16, - "optional": false - }, - { - "name": "version", - "type": 14, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", - "type": 19, - "optional": false - }, - { - "name": "version", - "type": 12, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "unknown" - } - ] - }, - "names": [ - "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" - ], - "sources": [ - "packages/subagent/subagent/src/catalog.ts:28" - ] - }, { "digest": "c99e20441bf15341e9db54385e558e5c03d21dff2eb1ac08ccbf47c2d8747954", "schema": { diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 08ae8cfcf1..952708e10a 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/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/subsystems/subagent.md -subagent.md: b6113686c677213bd052907b72eb54aa6e690489 -subagent.zh.md: 7a5bd28e0ab8a3a4384e14959145e4565d9d0d4b +subagent.md: 03172744ad388322fb4b292317aeec01acab6de9 +subagent.zh.md: 2c1399eeb697480ebfceef86f76b0cda5fc912d6 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index b6113686c6..03172744ad 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -219,7 +219,7 @@ type SubagentInterruptAuthority = ## Durable enumeration -`listChildren` reads the parent-owned catalog. `listDescendants` combines Session corpus identity with parent catalogs, including external leaves beneath ordinary Sessions. External one-shot entries carry an optional `external` outcome and cannot open a child Session or accept follow-ups. `pending` means no terminal outcome is recorded, not that a process is live. Complete results go to the caller or the parent completion notice. `list_agents` exposes only continuable children with `running` or `inactive` activity; see the [control tool](../../packages/subagent/tool-subagent-control/README.md#list_agents) for its scopes and diagnostics. +`listChildren` reads the parent-owned catalog. `listDescendants` combines Session corpus identity with parent catalogs, including external leaves beneath ordinary Sessions. External one-shot entries carry `external: true` and cannot open a child Session or accept follow-ups. Catalog entries record membership at creation without execution status. Complete results go to the caller or the parent completion notice. `list_agents` exposes only continuable children with `running` or `inactive` activity; see the [control tool](../../packages/subagent/tool-subagent-control/README.md#list_agents) for its scopes and diagnostics. ```ts type-equiv /** diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 7a5bd28e0a..2c1399eeb6 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -219,7 +219,7 @@ type SubagentInterruptAuthority = ## 持久枚举 -`listChildren` 读取父级拥有的目录。`listDescendants` 结合 Session 语料中的身份与父级目录,包括普通 Session 下的外部叶子项。外部一次性条目携带可选的 `external` 结果状态,不能打开子 Session 或接受后续消息。`pending` 表示尚无终态记录,不表示进程存活。完整结果交给调用方或父级完成通知。`list_agents` 只展示可继续子级,其活跃状态为 `running` 或 `inactive`;作用域与诊断信息见[控制工具](../../packages/subagent/tool-subagent-control/README.zh.md#list_agents)。 +`listChildren` 读取父级拥有的目录。`listDescendants` 结合 Session 语料中的身份与父级目录,包括普通 Session 下的外部叶子项。外部一次性条目携带 `external: true`,不能打开子 Session 或接受后续消息。目录在创建时记录成员关系,不携带执行状态。完整结果交给调用方或父级完成通知。`list_agents` 只展示可继续子级,其活跃状态为 `running` 或 `inactive`;作用域与诊断信息见[控制工具](../../packages/subagent/tool-subagent-control/README.zh.md#list_agents)。 ```ts type-equiv /** diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 9dc14fc396..89b02134f4 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-subagent/README.md -README.md: 75f1e46d55a540abdb6d1bb36526088af871d6cb -README.zh.md: 55173646ea63884b7803b951a2189acb68695f00 +README.md: eecb0e0d0a85de5ce67dbc674df8f966b020882e +README.zh.md: edfef9ee1dcb565dda247ca4e7f834d304566ed8 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 75f1e46d55..eecb0e0d0a 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Browse local subagent conversations and external tasks from the parent header. Local conversations show activity, token usage, and duration; continuable children accept follow-up prompts and support Stop. External tasks appear as non-navigable leaves with their last recorded outcome; pending does not assert process liveness. Historical one-shot conversations open as read-only records. The ordinary sidebar omits subagent conversations, and the separate `@` source inserts a running child's label without granting continuation authority. +Browse local subagent conversations and external tasks from the parent header. Local conversations show activity, token usage, and duration; continuable children accept follow-up prompts and support Stop. External tasks appear as non-navigable leaves without an execution status. Historical one-shot conversations open as read-only records. The ordinary sidebar omits subagent conversations, and the separate `@` source inserts a running child's label without granting continuation authority. ## Table of Contents diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 55173646ea..edfef9ee1d 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -从父会话页头浏览本地子代理对话与外部任务。本地对话展示活跃状态、token 用量和耗时;可继续子代理接受后续提示词并支持 Stop。外部任务显示为不可打开的叶子项,展示最近记录的结果状态;pending 不声明进程存活。历史一次性对话以只读记录打开。普通侧边栏省略子代理对话;独立的 `@` 来源插入运行中子代理的标签,不授予继续执行权限。 +从父会话页头浏览本地子代理对话与外部任务。本地对话展示活跃状态、token 用量和耗时;可继续子代理接受后续提示词并支持 Stop。外部任务显示为不可打开的叶子项,不展示执行状态。历史一次性对话以只读记录打开。普通侧边栏省略子代理对话;独立的 `@` 来源插入运行中子代理的标签,不授予继续执行权限。 ## 目录 diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 2aaf0b9820..5d95b29440 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -241,9 +241,9 @@ function CatalogRows({ const label = entry.label ?? entry.id const mode = external !== undefined ? t('mode.external') : entry.mode === 'unknown' ? t('mode.unknown') : entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') - const completed = external !== undefined ? external === 'completed' : entry.activity === 'inactive' + const completed = external === undefined && entry.activity === 'inactive' && summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true - const activity = external !== undefined ? t(`external.${external}`) : entry.activity === 'running' + const activity = external !== undefined ? undefined : entry.activity === 'running' ? t('activity.running') : completed ? t('activity.completed') diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 52eb13e8d5..575b249c97 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -23,12 +23,6 @@ export const zh = { 'load.error': '无法加载子代理', 'retry': '重试', 'mode.external': '外部任务', - 'external.pending': '结果待定', - 'external.completed': '已完成', - 'external.aborted': '已中止', - 'external.error': '失败', - 'external.refusal': '已拒绝', - 'external.max-tokens': '已达输出上限', 'mode.oneShot': '一次性', 'mode.continuable': '可继续', 'mode.unknown': '模式未知', @@ -72,12 +66,6 @@ export const en: Record = { 'load.error': 'Unable to load subagents', 'retry': 'Retry', 'mode.external': 'External task', - 'external.pending': 'Outcome pending', - 'external.completed': 'Completed', - 'external.aborted': 'Aborted', - 'external.error': 'Failed', - 'external.refusal': 'Refused', - 'external.max-tokens': 'Output limit reached', 'mode.oneShot': 'one-shot', 'mode.continuable': 'continuable', 'mode.unknown': 'unknown mode', diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 523afc6f40..50a0e52424 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -948,19 +948,17 @@ describe('SubagentReadOnlyComposer', () => { }) -it('updates an external leaf from shared projection values without opening a child Session', () => { - const external = (status: 'pending' | 'completed') => props(catalog({ entries: [ - { id: CHILD, mode: 'one-shot', label: 'External review', activity: 'inactive', external: status }, +it('shows external membership without an execution status or child Session navigation', () => { + const input = props(catalog({ entries: [ + { id: CHILD, mode: 'one-shot', label: 'External review', activity: 'inactive', external: true }, ] })) - const input = external('pending') - const view = render() + render() hoverCatalog(screen.getByRole('button', { name: /子代理/ })) - const row = screen.getByRole('treeitem', { name: /External review.*结果待定/ }) + const row = screen.getByRole('treeitem', { name: /External review.*外部任务/ }) expect(row.getAttribute('aria-disabled')).toBe('true') expect(row.hasAttribute('aria-expanded')).toBe(false) + expect(row.getAttribute('aria-label')).not.toMatch(/已完成|未运行|结果待定/) fireEvent.click(row) expect(input.openChild).not.toHaveBeenCalled() expect(input.openChildAside).not.toHaveBeenCalled() - view.rerender() - expect(screen.getByRole('treeitem', { name: /External review.*已完成/ })).toBeTruthy() }) diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 49430c2d20..995d5b7d6e 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -878,7 +878,7 @@ describe('WorkflowRunPanel', () => { }) it.each([ - ['external execution', listState({ projectionsBySession: { [PARENT_ID]: { state: 'ready', error: null, values: { subagentCatalog: [{ createdAt: 1, id: CHILD_ID, mode: 'one-shot', external: 'pending' }] } } } }), 'running'], + ['external execution', listState({ projectionsBySession: { [PARENT_ID]: { state: 'ready', error: null, values: { subagentCatalog: [{ createdAt: 1, id: CHILD_ID, mode: 'one-shot', external: true }] } } } }), 'running'], ['catalog absent', listState({ projectionsBySession: {} }), 'running'], ['catalog empty', listState({ projectionsBySession: { [PARENT_ID]: { state: 'ready', error: null, values: { subagentCatalog: [] } } } }), 'running'], ['wrong parent', listState({ projectionsBySession: { ['other' as SessionId]: { state: 'ready', error: null, values: { subagentCatalog: [{ createdAt: 1, id: CHILD_ID, mode: 'one-shot' }] } } } }), 'running'], diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 39d048d95b..3af4220d34 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4696,10 +4696,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EpochHeader', declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n tools?: ToolSchema[];\n system?: never;\n}', }, - { - name: 'ExternalSubagentStatus', - declaration: 'export type ExternalSubagentStatus = \'pending\' | \'completed\' | \'max-tokens\' | \'aborted\' | \'refusal\' | \'error\';', - }, { name: 'FeedbackCategory', declaration: 'export type FeedbackCategory = \'task-result\' | \'instruction-following\' | \'product-interaction\' | \'service-stability\' | \'resource-cost\' | \'security-privacy-permission\' | \'other\';', @@ -6426,11 +6422,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentCatalogEntry', - declaration: 'export type SubagentCatalogEntry = {\n readonly id: SessionId;\n readonly createdAt: number;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n readonly external?: ExternalSubagentStatus;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n} | {\n readonly mode: \'unknown\';\n readonly label?: string;\n});', + declaration: 'export type SubagentCatalogEntry = {\n readonly id: SessionId;\n readonly createdAt: number;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n readonly external?: true;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n} | {\n readonly mode: \'unknown\';\n readonly label?: string;\n});', }, { name: 'SubagentCatalogRow', - declaration: 'export type SubagentCatalogRow = {\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n} & ({\n readonly mode: \'one-shot\';\n readonly external?: ExternalSubagentStatus;\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n});', + declaration: 'export type SubagentCatalogRow = {\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n} & ({\n readonly mode: \'one-shot\';\n readonly external?: true;\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n});', }, { name: 'SubagentDescendantListEntry', diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index 467e3ca59e..7512038cd6 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/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/session-format-v3-to-v4/README.md -README.md: 593b065c50e0e3b2eecf0c88c6aaa547a51aaf6b -README.zh.md: 26809e369330b9f06520a9a97d5a815ec46d0a65 +README.md: 60f3c7e41b133a6dbd410fe2e99a134c337efe07 +README.zh.md: 568ef405478681f115ac4a3a469db89675a88aa7 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 593b065c50..60f3c7e41b 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -243,7 +243,7 @@ Current common admission does not validate each user/tool/developer content bloc | `compaction/start`, `compaction/summary`, `compaction/end` | Match compaction id, source command, and the active turn context. Summary spans name exact current-surface nodes and exclude the protected head; successful completion has one summary. Inherited unfinished compactions expire at the end-seed marker. | | `compaction/prune` | Its span names exact current-surface nodes and excludes the protected head; it does not require a compaction transaction or its owner fields. | | Compact checkpoint replacement | Its `compact-checkpoint` source identifies the active compaction. | -| Native `subagent/catalog` | Validates own version-0/version-1 payloads after the inherited cut. A child id is unique except for one external pending-to-terminal update with unchanged mode, creation time, and label. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | +| Native `subagent/catalog` | Validates own version-0/version-1 payloads after the inherited cut. Each child id must be unique. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | | Inherited cut and delivery | Apply the marker, coordinate, and generation-ownership rules stated above. | These checks are generation-owned in [relationships.ts](src/relationships.ts). Full common message/envelope acceptance and plugin-owned message projections additionally use the installed Session; the exported V4 restorer alone is not a replacement for complete catalog restoration. diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 26809e3693..568ef40547 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -243,7 +243,7 @@ System image 接纳要求非空 attachment id、PNG/JPEG/WebP/GIF MIME 类 | `compaction/start`、`compaction/summary`、`compaction/end` | 匹配 compaction id、源 command 和活动 turn 上下文。Summary 区间引用精确的当前表面节点且排除 protected head;成功完成需要一个 summary。继承的未完成 compaction 在 end-seed marker 处过期。 | | `compaction/prune` | 其区间引用精确的当前表面节点且排除 protected head;它不要求存在 compaction 事务或其所有者字段。 | | Compact checkpoint 替换 | 其 `compact-checkpoint` 来源标识活动 compaction。 | -| 原生 `subagent/catalog` | 校验继承截点之后的自身 version-0/version-1 载荷。子 id 保持唯一,例外是一次外部 pending 到终态的更新,其模式、创建时间与标签必须不变。原生读取不收集子日志,也不比较其物理事实;继承条目不建立自身成员关系。 | +| 原生 `subagent/catalog` | 校验继承截点之后的自身 version-0/version-1 载荷。每个子 id 必须唯一。原生读取不收集子日志,也不比较其物理事实;继承条目不建立自身成员关系。 | | 继承截点与 delivery | 应用上文的 marker、坐标及代际归属规则。 | 这些检查由 [relationships.ts](src/relationships.ts) 按代际拥有。完整的通用消息/信封接纳与插件拥有的消息投影还使用已安装 Session;单独的导出 V4 恢复器不能替代完整 catalog 恢复。 diff --git a/packages/session/session-format-v3-to-v4/src/validation.ts b/packages/session/session-format-v3-to-v4/src/validation.ts index 8b1c6e4cde..8c0e74d61d 100644 --- a/packages/session/session-format-v3-to-v4/src/validation.ts +++ b/packages/session/session-format-v3-to-v4/src/validation.ts @@ -2,7 +2,7 @@ import { isAbsolute } from 'node:path' import { SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format' -import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' +import type { SessionFormatArtifact, SessionFormatEvent } from '@deepseek-ai/dsh-session-format' import { assertV4DeveloperData } from './developer.ts' import { assertV4LifecycleRelationships } from './relationships.ts' import { assertV4MessageSources } from './message-sources.ts' @@ -108,7 +108,7 @@ export function validateDeliveryAccepted(event: SessionFormatEvent, currentVersi * @param knownEventTypes - installed event types whose payloads this reader interprets. */ export function assertReleasedV4Relationships(artifact: SessionFormatArtifact, knownEventTypes: ReadonlySet): void { - const catalogs = new Map() + const ids = new Set() for (const event of artifact.events) { if (!knownEventTypes.has(event.type)) continue assertV4DeveloperData(event) @@ -122,13 +122,8 @@ export function assertReleasedV4Relationships(artifact: SessionFormatArtifact, k if (event.type === 'subagent/catalog' && event.seq >= artifact.inheritedEventCount) { const fact = catalogFact(event.data) const id = fact['childId'] as string - const previous = catalogs.get(id) - if (previous !== undefined && !(previous['mode'] === 'one-shot' && fact['mode'] === 'one-shot' - && previous['external'] === 'pending' && typeof fact['external'] === 'string' && fact['external'] !== 'pending' - && previous['childCreatedAt'] === fact['childCreatedAt'] && previous['label'] === fact['label'])) { - throw new SessionFormatError(`duplicate catalog child ${id}`) - } - catalogs.set(id, fact) + if (ids.has(id)) throw new SessionFormatError(`duplicate catalog child ${id}`) + ids.add(id) } } assertV4LifecycleRelationships(artifact, knownEventTypes) diff --git a/packages/session/session-format-v3-to-v4/tests/codec.spec.ts b/packages/session/session-format-v3-to-v4/tests/codec.spec.ts index 559bc5b6ce..dbbf4a06f7 100644 --- a/packages/session/session-format-v3-to-v4/tests/codec.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/codec.spec.ts @@ -162,20 +162,9 @@ describe('V4 framing and restoration', () => { }) -it('restores one external pending-to-terminal catalog update while retaining local uniqueness', () => { - const data = { ...(catalog.data as SessionFormatJsonObject), mode: 'one-shot', label: 'External', external: 'pending' } - const start = { ...catalog, data } - const end = { ...catalog, seq: 1, data: { ...data, external: 'completed' } } - expect(restore([start, end]).events).toEqual([start, end]) - for (const changed of [ - { ...data }, - { ...end.data, mode: 'continuable' }, - { ...end.data, childCreatedAt: 999 }, - { ...end.data, label: 'different child' }, - { ...end.data, external: undefined }, - ]) { - expect(() => restore([start, { ...end, data: changed as SessionFormatJsonObject }])).toThrow('duplicate catalog child') - } - expect(() => restore([start, end, { ...end, seq: 2 }])).toThrow('duplicate catalog child') - expect(() => restore([catalog, end])).toThrow('duplicate catalog child') +it('restores external membership and rejects duplicate catalog children', () => { + const data = { ...(catalog.data as SessionFormatJsonObject), mode: 'one-shot', label: 'External', external: true } + const entry = { ...catalog, data } + expect(restore([entry]).events).toEqual([entry]) + expect(() => restore([entry, { ...entry, seq: 1 }])).toThrow('duplicate catalog child') }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 08bbe3cdd1..fa70da8bb8 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: 9424d0e71212aae455e72f1552b80af6504df811 -README.zh.md: e9760d94361a4d7efe1c0467d01c20eb340aa77b +README.md: 2316002d7607a5eccad1b5acfe59822722b393cc +README.zh.md: 3568bbb3dd69def11b29b08290ff2c9d88c278f7 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 9424d0e712..2316002d76 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -110,7 +110,7 @@ The service validates requested capabilities before creation. Local providers su The manager reserves child identity and capacity, materializes a local Agent or external execution, and accepts the initial task. Local `result` becomes ready after Agent activity, pending input, and owned descendants finish, with activity revalidated after the final Session flush. The manager publishes this result while closing admission, before releasing the local handle. External results can become ready before process cleanup finishes. Closing admission and disposing the handle prevent late work from entering a released Agent. Parent delivery emits its notice after settlement; caller delivery leaves collection to the awaiting workflow. Headless hosts repeat `agent.whenIdle()` and `waitForChildren(agent)` until no child work remains, so completion notices can produce the final parent answer. -Successful local and external creation appends a `subagent/catalog` fact to the parent Session. External entries have no child Session and carry only their last recorded outcome. Terminal updates replace the same catalog row; pending does not imply a live process. Complete results go to the caller or the parent completion notice. Projections exclude inherited facts. Direct lists read one parent projection; descendant lists enumerate Session-backed children through corpus identity. Catalog payload v0 records known modes; v1 also accepts unknown mode. Readers support both versions. Historical migration appends a v1 `subagent/catalog` from a readable child header when its descriptor is unavailable; normal creation retains v0. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative. +Successful local and external creation appends a `subagent/catalog` fact to the parent Session. External entries use `external: true` to identify children without a local Session. Catalog entries record membership once at creation and carry no execution status. Complete results go to the caller or the parent completion notice. Projections exclude inherited facts. Direct lists read one parent projection; descendant lists enumerate Session-backed children through corpus identity. Catalog payload v0 records known modes; v1 also accepts unknown mode. Readers support both versions. Historical migration appends a v1 `subagent/catalog` from a readable child header when its descriptor is unavailable; normal creation retains v0. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative. ### Ownership and invariants diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index e9760d9436..3568bbb3dd 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -110,7 +110,7 @@ kind: "package-reference" 管理器预留子级身份与容量,创建本地 Agent 或外部执行,并接受初始任务。本地 Agent 活动、待处理输入及所拥有的后代均结束后,管理器在最终 Session flush 后重新验证活动状态,使 `result` 就绪。管理器在关闭准入时发布结果,随后释放本地句柄。外部结果可以早于进程清理完成而就绪。关闭准入并释放句柄可防止迟到工作进入已释放的 Agent。父级投递在结算后发送通知;调用方投递由等待中的工作流收集。Headless 宿主交替等待 `agent.whenIdle()` 与 `waitForChildren(agent)`,直至没有子级工作,让完成通知可驱动父级生成最终答案。 -本地与外部创建成功时,都向父 Session 追加 `subagent/catalog` 事实。外部条目没有子 Session,只携带最近记录的结果状态。终态更新替换同一目录条目;pending 不表示进程正在运行。完整结果交给调用方或父级完成通知。投影排除继承事实。直接列表读取一个父级投影;后代列表通过语料中的身份枚举具有 Session 的子级。Catalog 载荷 v0 记录已知模式,v1 还接受未知模式,读取器支持两版。历史迁移在 descriptor 不可用时根据可读子 header 追加 v1 `subagent/catalog`;正常创建保留 v0。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。 +本地与外部创建成功时,都向父 Session 追加 `subagent/catalog` 事实。外部条目以 `external: true` 标记没有本地 Session 的子级。目录仅在创建时记录一次成员关系,不携带执行状态。完整结果交给调用方或父级完成通知。投影排除继承事实。直接列表读取一个父级投影;后代列表通过语料中的身份枚举具有 Session 的子级。Catalog 载荷 v0 记录已知模式,v1 还接受未知模式,读取器支持两版。历史迁移在 descriptor 不可用时根据可读子 header 追加 v1 `subagent/catalog`;正常创建保留 v0。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。 ### 所有权与不变式 diff --git a/packages/subagent/subagent/src/catalog.ts b/packages/subagent/subagent/src/catalog.ts index 1c0d4f9031..9925073e08 100644 --- a/packages/subagent/subagent/src/catalog.ts +++ b/packages/subagent/subagent/src/catalog.ts @@ -15,13 +15,13 @@ import type { SessionLogOffset, } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import type { ExternalSubagentStatus, SubagentCatalogEntry } from './projection-types.ts' +import type { SubagentCatalogEntry } from './projection-types.ts' /** Catalog payload version emitted by live child creation. */ export const SUBAGENT_CATALOG_VERSION = 0 type KnownCatalogMode = - | { readonly mode: 'one-shot'; readonly label?: string; readonly external?: ExternalSubagentStatus } + | { readonly mode: 'one-shot'; readonly label?: string; readonly external?: true } | { readonly mode: 'continuable'; readonly label: string } /** Parent catalog v0 records known modes; v1 also retains children with unknown mode. */ @@ -57,7 +57,7 @@ const oneShotCatalogSchema = z.object({ childCreatedAt: z.number().int().nonnegative(), mode: z.literal('one-shot'), label: z.string().optional(), - external: z.enum(['pending', 'completed', 'max-tokens', 'aborted', 'refusal', 'error']).optional(), + external: z.literal(true).optional(), }).strict() const continuableCatalogSchema = z.object({ version: z.union([z.literal(0), z.literal(1)]), @@ -103,12 +103,12 @@ declare module '@deepseek-ai/dsh-session-projection/types' { * @returns current direct-child rows in parent catalog event order. */ function subagentCatalogEntries(state: SubagentCatalogState): SubagentCatalogEntry[] { - const entries = new Map() + const entries: SubagentCatalogEntry[] = [] for (const data of iterateChunkedList(state.head)) { const { version: _version, childId, childCreatedAt, ...descriptor } = data - entries.set(childId, { id: childId, createdAt: childCreatedAt, ...descriptor }) + entries.push({ id: childId, createdAt: childCreatedAt, ...descriptor }) } - return [...entries.values()] + return entries } /** Parent-owned direct-child catalog projection; invalid own facts reject restoration. */ @@ -120,7 +120,7 @@ export const subagentCatalogProjectionDefinition = { if (event.type !== 'subagent/catalog' || event.seq < state.inheritedEventCount) return state return { ...state, head: appendChunkedList(state.head, eventDataSchema.parse(event.data)) } }, - stateVersion: 4, + stateVersion: 5, wire: { viewSchema, view: subagentCatalogEntries }, } satisfies ProjectionDefinition<'subagentCatalog', SubagentCatalogState> diff --git a/packages/subagent/subagent/src/continuation-activation.ts b/packages/subagent/subagent/src/continuation-activation.ts index d550734e9b..e345e61ff8 100644 --- a/packages/subagent/subagent/src/continuation-activation.ts +++ b/packages/subagent/subagent/src/continuation-activation.ts @@ -92,8 +92,6 @@ export interface Activation { * another runtime-incarnation reference. Non-empty blocks settlement. */ readonly ownedChildren: Set - /** Complete the accepted external catalog fact after resource release. */ - settleCatalog?: (status: SubagentResult['stopReason']) => void /** The lifecycle observer that emits this epoch's start and terminal edges. */ readonly observer: ActivationObserver /** @@ -980,14 +978,6 @@ export class ContinuableActivationRegistry { activation.releaseSlot() if (failure !== undefined) result = { output: [], stopReason: 'error' } activation.result.resolve(result) - if (activation.settleCatalog !== undefined) { - try { - activation.settleCatalog(result.stopReason) - } catch (error: unknown) { - failure ??= new SubagentError(`subagent "${childId}" catalog settlement could not be recorded`, 'ACTIVATION_TEARDOWN_FAILED', { cause: error }) - } - } - if (failure !== undefined) result = { output: [], stopReason: 'error' } this.notifySettlement(activation, result, includeOutput) this.releaseOwnership(childId) activation.observer.settle(result) diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 789a2b0c74..90084d0a20 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -43,7 +43,6 @@ import { SubagentError } from './error.ts' import { isAdjacentAgentSendMessageTool } from './internal.ts' import type { ActivationObserver } from './lifecycle.ts' import type { - SubagentStopReason, ContinuableCreateRequest, ContinuableCreateSpec, SubagentInterruptAuthority, @@ -225,14 +224,9 @@ export class SubagentContinuationManager { this.activations.authorizeLineage(parent, activation.childId, activation.parent.id) spec.signal.throwIfAborted() const childId = activation.childId - const childCreatedAt = Date.now() - const recordCatalog = (external: 'pending' | SubagentStopReason): void => { - parent.session.append('subagent/catalog', { - version: 0, childId, childCreatedAt, mode: 'one-shot', label: spec.label, external, - }) - } - recordCatalog('pending') - activation.settleCatalog = recordCatalog + parent.session.append('subagent/catalog', { + version: 0, childId, childCreatedAt: Date.now(), mode: 'one-shot', label: spec.label, external: true, + }) this.activations.announce(activation) return this.receipt(activation) } catch (error: unknown) { diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts index eb772bf6c2..27ed730b7b 100644 --- a/packages/subagent/subagent/src/control-types.ts +++ b/packages/subagent/subagent/src/control-types.ts @@ -8,7 +8,6 @@ import type { PromptContentPart } from '@deepseek-ai/dsh-attachment/types' import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { ExternalSubagentStatus } from './projection-types.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' /** @@ -34,7 +33,7 @@ export type SubagentCatalogRow = | { /** A terminal one-shot child. */ readonly mode: 'one-shot' - readonly external?: ExternalSubagentStatus + readonly external?: true /** Optional durable creation label from the owning catalog or child descriptor. */ readonly label?: string } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 3ee490a80b..bfb2fb661e 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -115,7 +115,7 @@ export type { AgentMessageSource, SubagentSettledMessageSource } from './continu export type * from './control-types.ts' export type { SubagentDescendantListEntry } from './list-children.ts' export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts' -export type { ExternalSubagentStatus, SubagentCatalogEntry, SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' +export type { SubagentCatalogEntry, SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' declare module '@deepseek-ai/cordis' { interface Context { diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index 05b93e113a..94d1683c20 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -6,9 +6,6 @@ import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' -/** Last recorded external execution outcome; pending does not assert process liveness. */ -export type ExternalSubagentStatus = 'pending' | 'completed' | 'max-tokens' | 'aborted' | 'refusal' | 'error' - /** One current direct-child discovery row materialized from parent facts. */ export type SubagentCatalogEntry = & { @@ -16,7 +13,7 @@ export type SubagentCatalogEntry = readonly createdAt: number } & ( - | { readonly mode: 'one-shot'; readonly label?: string; readonly external?: ExternalSubagentStatus } + | { readonly mode: 'one-shot'; readonly label?: string; readonly external?: true } | { readonly mode: 'continuable'; readonly label: string } | { readonly mode: 'unknown'; readonly label?: string } ) diff --git a/packages/subagent/subagent/tests/catalog.spec.ts b/packages/subagent/subagent/tests/catalog.spec.ts index cc4d4e3908..6eb9a41a59 100644 --- a/packages/subagent/subagent/tests/catalog.spec.ts +++ b/packages/subagent/subagent/tests/catalog.spec.ts @@ -157,8 +157,8 @@ describe('subagent catalog projection', () => { it.each([ { version: 0, childId: 'child', childCreatedAt: 0, mode: 'unknown' }, - { version: 1, childId: 'child', childCreatedAt: 0, mode: 'unknown', external: 'pending' }, - { version: 1, childId: 'child', childCreatedAt: 0, mode: 'continuable', label: 'child', external: 'pending' }, + { version: 1, childId: 'child', childCreatedAt: 0, mode: 'unknown', external: true }, + { version: 1, childId: 'child', childCreatedAt: 0, mode: 'continuable', label: 'child', external: true }, { version: 9, childId: 'child', childCreatedAt: 0, mode: 'one-shot' }, { version: 0, childId: 'child', childCreatedAt: 0, mode: 'continuable' }, { version: 0, childId: 'child', childCreatedAt: -1, mode: 'one-shot' }, @@ -181,22 +181,21 @@ describe('subagent catalog projection', () => { describe('external catalog membership', () => { - it.each([0, 1] as const)('updates external leaves alongside unknown children at payload version %s', (version) => { + it.each([0, 1] as const)('restores external leaves alongside unknown children at payload version %s', (version) => { const start = fact(0, 'external', 1, { mode: 'one-shot', label: 'Review' }) - const pending = { ...start, data: { ...start.data, version, mode: 'one-shot' as const, external: 'pending' as const } } - const end = { ...pending, seq: SessionSeq(2), data: { ...pending.data, external: 'completed' as const } } + const entry = { ...start, data: { ...start.data, version, mode: 'one-shot' as const, external: true as const } } const unknown: SessionEvent<'subagent/catalog'> = { type: 'subagent/catalog', seq: SessionSeq(3), time: 0, data: { version: 1, childId: SessionId('unreadable'), childCreatedAt: 3, mode: 'unknown' }, } - const state = fold([pending, fact(1, 'local', 2, { mode: 'continuable', label: 'Implement' }), end, unknown]) + const state = fold([entry, fact(1, 'local', 2, { mode: 'continuable', label: 'Implement' }), unknown]) const restored = subagentCatalogProjectionDefinition.stateSchema.parse(JSON.parse(JSON.stringify(state))) expect(subagentCatalogProjectionDefinition.wire.view(restored)).toEqual([ - { id: SessionId('external'), createdAt: 1, mode: 'one-shot', label: 'Review', external: 'completed' }, + { id: SessionId('external'), createdAt: 1, mode: 'one-shot', label: 'Review', external: true }, { id: SessionId('local'), createdAt: 2, mode: 'continuable', label: 'Implement' }, { id: SessionId('unreadable'), createdAt: 3, mode: 'unknown' }, ]) - expect(subagentCatalogProjectionDefinition.wire.view(fold([pending]))[0]).toMatchObject({ external: 'pending' }) + expect(subagentCatalogProjectionDefinition.wire.view(fold([entry]))[0]).toMatchObject({ external: true }) expect(subagentCatalogProjectionDefinition.wire.viewSchema.safeParse([{ id: 'external', createdAt: 1, mode: 'one-shot', external: 'invalid' }]).success).toBe(false) }) }) diff --git a/packages/subagent/subagent/tests/external-activation.spec.ts b/packages/subagent/subagent/tests/external-activation.spec.ts index 09341fedae..c31cfba74d 100644 --- a/packages/subagent/subagent/tests/external-activation.spec.ts +++ b/packages/subagent/subagent/tests/external-activation.spec.ts @@ -138,7 +138,7 @@ describe('external subagent activations', () => { await disposal await activation.dispose() expect(backend.dispose).toHaveBeenCalledTimes(1) - expect(fixture.records()).toMatchObject([{ id: activation.childId, external: 'completed' }]) + expect(fixture.records()).toMatchObject([{ id: activation.childId, external: true }]) expect(fixture.parent.inbox.nextTurn).toEqual([]) }) @@ -183,7 +183,7 @@ describe('external subagent activations', () => { backend.result.resolve(failed) await expect(activation.result).resolves.toEqual(failed) await activation.dispose() - expect(fixture.records()).toMatchObject([{ id: activation.childId, external: 'error' }]) + expect(fixture.records()).toMatchObject([{ id: activation.childId, external: true }]) if (delivery === 'caller') { expect(fixture.parent.inbox.nextTurn).toEqual([]) } else { @@ -198,31 +198,6 @@ describe('external subagent activations', () => { } }) - it('reports a catalog settlement write failure and releases capacity', async () => { - const backend = execution() - backend.cleanup.resolve(undefined) - const fixture = await setup(async () => backend.run) - const activation = await fixture.start('parent') - const append = fixture.parent.session.append.bind(fixture.parent.session) - const spy = vi.spyOn(fixture.parent.session, 'append').mockImplementation((...args) => { - if (args[0] === 'subagent/catalog') throw new Error('storage unavailable') - return Reflect.apply(append, fixture.parent.session, args) as ReturnType - }) - try { - backend.result.resolve(completed) - await expect(activation.result).resolves.toEqual(completed) - await expect(activation.dispose()).rejects.toThrow('catalog settlement could not be recorded') - expect(await fixture.ctx.subagents.waitForChildren(fixture.parent)).toBe(false) - expect(fixture.records()).toMatchObject([{ id: activation.childId }]) - expect(fixture.parent.inbox.nextTurn[0]?.content).toEqual([ - { type: 'text', text: `Background subagent ${activation.childId} failed before it finished.` }, - { type: 'text', text: 'It left no closing message.' }, - ]) - } finally { - spy.mockRestore() - } - }) - it('reports cleanup failure separately from a completed result without exposing the private cause', async () => { const backend = execution() const fixture = await setup(async () => backend.run) @@ -240,13 +215,13 @@ describe('external subagent activations', () => { await expect(activation.result).resolves.toEqual(completed) expect(activation.dispose()).toBe(disposal) expect(fixture.records()).toMatchObject([{ - id: activation.childId, external: 'error', + id: activation.childId, external: true, }]) }) }) -it('discovers an external leaf through the parent catalog and publishes a small terminal update', async () => { +it('records external membership at creation without publishing settlement updates', async () => { const backend = execution() const fixture = await setup(async () => backend.run) const snapshots: unknown[] = [] @@ -254,7 +229,7 @@ it('discovers an external leaf through the parent catalog and publishes a small const unsubscribe = fixture.ctx.sessionProjections.onChanged(() => { snapshots.push(fixture.records()) }) try { const activation = await fixture.start() - const expected = { id: activation.childId, mode: 'one-shot', label: 'External work', external: 'pending' } + const expected = { id: activation.childId, mode: 'one-shot', label: 'External work', external: true } expect(await fixture.ctx.subagents.listChildren(fixture.parent.id)).toMatchObject([expected]) expect(await fixture.ctx.subagents.listDescendants(fixture.parent.id)).toEqual([]) expect(fixture.ctx.sessions.get(activation.childId)).toBeUndefined() @@ -263,8 +238,8 @@ it('discovers an external leaf through the parent catalog and publishes a small backend.cleanup.resolve(undefined) await expect(activation.result).resolves.toEqual(result) await activation.dispose() - expect(await fixture.ctx.subagents.listChildren(fixture.parent.id)).toMatchObject([{ ...expected, external: 'completed' }]) - expect(snapshots).toHaveLength(2) + expect(await fixture.ctx.subagents.listChildren(fixture.parent.id)).toMatchObject([expected]) + expect(snapshots).toHaveLength(1) expect(JSON.stringify(snapshots).length).toBeLessThan(1_000) } finally { unsubscribe() diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 7b3a36eea5..fae30701fe 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -302,7 +302,7 @@ describe('dsh-tool-subagent-control/list-agents', () => { await waitNoActivation(ctx, started.childId) }) - it.each([undefined, 'pending', 'completed'] as const)('omits one-shot intermediates with outcome %s while surfacing their descendants', async (external) => { + it.each([undefined, true] as const)('omits one-shot intermediates with external marker %s while surfacing their descendants', async (external) => { const { ctx, parent } = await setup([]) // Deterministic service rows: a one-shot intermediate owning a continuable // leaf, plus a positioned diagnostic. The tool filters only the one-shot. @@ -355,17 +355,14 @@ describe('dsh-tool-subagent-control/list-agents', () => { expect(listDescendants).toHaveBeenCalledWith(parent.id, signal) }) - it.each(['pending', 'completed', 'max-tokens', 'aborted', 'refusal', 'error'] as const)( - 'omits external children with recorded outcome %s', - async (external) => { - const { ctx, parent } = await setup([]) - vi.spyOn(ctx.subagents, 'listChildren').mockResolvedValue([ - { id: SessionId('external-task'), createdAt: 1, mode: 'one-shot', external }, - { id: SessionId('resumable-child'), createdAt: 2, mode: 'continuable', label: 'local task' }, - ]) - const result = await callTool(ctx, 'list_agents', {}, parent) - expect(result.isError).toBe(false) - expect(text(result)).toBe('resumable-child [inactive] — local task') - }, - ) + it('omits external children', async () => { + const { ctx, parent } = await setup([]) + vi.spyOn(ctx.subagents, 'listChildren').mockResolvedValue([ + { id: SessionId('external-task'), createdAt: 1, mode: 'one-shot', external: true }, + { id: SessionId('resumable-child'), createdAt: 2, mode: 'continuable', label: 'local task' }, + ]) + const result = await callTool(ctx, 'list_agents', {}, parent) + expect(result.isError).toBe(false) + expect(text(result)).toBe('resumable-child [inactive] — local task') + }) }) From 44121c69a7505e7f9d8e3dbaa8ab3e2defe10640 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 20 Sep 2026 17:44:56 +0800 Subject: [PATCH 022/402] test(sdk): record local activation final answer delivery --- .../advanced/result.json | 64 ++++++++++++++----- .../advanced/session.v4.jsonl | 4 +- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index b1992ab459..30d9d06ae1 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -1195,6 +1195,14 @@ { "type": "text", "text": "Background subagent {{child-1}} finished and will do no further work unless you send it more." + }, + { + "type": "text", + "text": "Its closing message:" + }, + { + "type": "text", + "text": "DIRECT_CHILD_OK" } ], "source": { @@ -1464,6 +1472,14 @@ { "type": "text", "text": "Background subagent {{child-1}} finished and will do no further work unless you send it more." + }, + { + "type": "text", + "text": "Its closing message:" + }, + { + "type": "text", + "text": "DIRECT_CHILD_OK" } ], "source": { @@ -3674,6 +3690,14 @@ { "type": "text", "text": "Background subagent {{child-1}} finished and will do no further work unless you send it more." + }, + { + "type": "text", + "text": "Its closing message:" + }, + { + "type": "text", + "text": "DIRECT_CHILD_OK" } ], "source": { @@ -4403,22 +4427,6 @@ "status": "idle" } }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "event": { - "type": "tool-workflow/agent-end", - "seq": 56, - "time": 0, - "data": { - "runId": "{{workflow-run}}", - "seq": 1, - "outcome": "completed" - } - } - } - }, { "method": "subagent.finished", "payload": { @@ -4436,6 +4444,22 @@ ] } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool-workflow/agent-end", + "seq": 56, + "time": 0, + "data": { + "runId": "{{workflow-run}}", + "seq": 1, + "outcome": "completed" + } + } + } + }, { "method": "session.event", "payload": { @@ -4546,6 +4570,14 @@ { "type": "text", "text": "Background subagent {{child-1}} finished and will do no further work unless you send it more." + }, + { + "type": "text", + "text": "Its closing message:" + }, + { + "type": "text", + "text": "DIRECT_CHILD_OK" } ], "source": { diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.v4.jsonl index c731d09f93..329c9868d2 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.v4.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.v4.jsonl @@ -49,7 +49,7 @@ {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":48}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{child-1}} finished and will do no further work unless you send it more."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{child-1}} finished and will do no further work unless you send it more.","senderSessionId":"{{child-1}}"},"role":"user","id":"{{messageId}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{child-1}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{child-1}} finished and will do no further work unless you send it more.","senderSessionId":"{{child-1}}"},"role":"user","id":"{{messageId}}"}]}} {"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-workflow","name":"workflow","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-workflow","args":["{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} {"type":"tool-workflow/run-start","data":{"runId":"{{workflow-run}}","name":"advanced-exe-snapshot"}} @@ -61,7 +61,7 @@ {"type":"step/end","data":{"turn":1,"step":5}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":6}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{child-1}} finished and will do no further work unless you send it more."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{child-1}} finished and will do no further work unless you send it more.","senderSessionId":"{{child-1}}"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{child-1}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{child-1}} finished and will do no further work unless you send it more.","senderSessionId":"{{child-1}}"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":62}} {"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{message:1}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["ADVANCED_EXECUTABLE_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} From c8820699285391b272712079eb4884749bb75746 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 20 Sep 2026 17:53:43 +0800 Subject: [PATCH 023/402] test(subagent): record creation-only external catalogs --- snapshots/sdk/sdk.snapshot.ts | 5 ++- .../notifications.expected.jsonl | 44 +++++++++---------- .../session.v4.jsonl | 12 +++-- .../notifications.expected.jsonl | 21 +++++---- .../session.v4.jsonl | 3 +- .../session.v4.jsonl | 18 +++----- .../subagent-acp-diagnostic/session.v4.jsonl | 8 ++-- 7 files changed, 50 insertions(+), 61 deletions(-) diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 012e327c5b..0f2c61bf37 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -844,8 +844,9 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { && ((event.data as JsonObject).source as JsonObject).kind === 'subagent-settled') const expectedCount = scenario.name === 'subagent-dsh-sdk-diagnostic' ? 2 : 1 expect(notices).toHaveLength(expectedCount) - expect(parentEvents.filter(event => event.type === 'subagent/catalog' && (event.data as JsonObject | undefined)?.external === 'pending')).toHaveLength(expectedCount) - expect(parentEvents.filter(event => event.type === 'subagent/catalog' && typeof (event.data as JsonObject | undefined)?.external === 'string' && (event.data as JsonObject).external !== 'pending')).toHaveLength(expectedCount) + const catalog = parentEvents.filter(event => event.type === 'subagent/catalog') + expect(catalog).toHaveLength(expectedCount) + expect(catalog.every(event => (event.data as JsonObject).external === true)).toBe(true) const expectedContent = scenario.name === 'subagent-dsh-sdk-diagnostic' ? 'partial child loader answer' : 'child route: mock/mock-routed/max/777; cwd:' diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/notifications.expected.jsonl b/snapshots/sdk/subagent-dsh-sdk-diagnostic/notifications.expected.jsonl index d4505e83cf..c1c0baa685 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/notifications.expected.jsonl +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/notifications.expected.jsonl @@ -12,27 +12,25 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session-log-deepseek/delivery-accepted","seq":13,"time":0,"data":{"sessionId":"{{sessionId}}","sessionFormatVersion":4,"throughSeq":12}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dsh_sdk_first","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe first DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_dsh_sdk_first","name":"subagent_dsh_sdk","args":["{\"description\":\"Observe first DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_first","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe first DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_dsh_sdk_first","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe first DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":16,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Observe first DSH SDK failure","external":"pending"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Observe first DSH SDK failure","external":"error"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":18,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial child loader answer"},{"type":"text","text":"Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} failed before it finished.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":19,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dsh_sdk_first"},"toolCallId":"call_dsh_sdk_first","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":21,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":22,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial child loader answer"},{"type":"text","text":"Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} failed before it finished.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session-log-deepseek/delivery-accepted","seq":24,"time":0,"data":{"sessionId":"{{sessionId}}","sessionFormatVersion":4,"throughSeq":23}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dsh_sdk_second","name":"subagent_dsh_sdk_second","arguments":"{\"description\":\"Observe second DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_dsh_sdk_second","name":"subagent_dsh_sdk_second","args":["{\"description\":\"Observe second DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_second","name":"subagent_dsh_sdk_second","arguments":"{\"description\":\"Observe second DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":26,"time":0,"data":{"turn":1,"step":2,"callId":"call_dsh_sdk_second","name":"subagent_dsh_sdk_second","arguments":"{\"description\":\"Observe second DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":27,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Observe second DSH SDK failure","external":"pending"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":28,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Observe second DSH SDK failure","external":"error"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":29,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial child loader answer"},{"type":"text","text":"Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} failed before it finished.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":30,"time":0,"data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dsh_sdk_second"},"toolCallId":"call_dsh_sdk_second","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[26],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":32,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":33,"time":0,"data":{"turn":1,"step":3}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":34,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial child loader answer"},{"type":"text","text":"Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} failed before it finished.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session-log-deepseek/delivery-accepted","seq":35,"time":0,"data":{"sessionId":"{{sessionId}}","sessionFormatVersion":4,"throughSeq":34}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":36,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":37,"time":0,"data":{"turn":1,"step":3}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":38,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":16,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Observe first DSH SDK failure","external":true}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":17,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial child loader answer"},{"type":"text","text":"Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} failed before it finished.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dsh_sdk_first"},"toolCallId":"call_dsh_sdk_first","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":22,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial child loader answer"},{"type":"text","text":"Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} failed before it finished.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session-log-deepseek/delivery-accepted","seq":23,"time":0,"data":{"sessionId":"{{sessionId}}","sessionFormatVersion":4,"throughSeq":22}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dsh_sdk_second","name":"subagent_dsh_sdk_second","arguments":"{\"description\":\"Observe second DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_dsh_sdk_second","name":"subagent_dsh_sdk_second","args":["{\"description\":\"Observe second DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_second","name":"subagent_dsh_sdk_second","arguments":"{\"description\":\"Observe second DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"call_dsh_sdk_second","name":"subagent_dsh_sdk_second","arguments":"{\"description\":\"Observe second DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":26,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Observe second DSH SDK failure","external":true}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":27,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial child loader answer"},{"type":"text","text":"Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} failed before it finished.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dsh_sdk_second"},"toolCallId":"call_dsh_sdk_second","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[25],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":30,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":31,"time":0,"data":{"turn":1,"step":3}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":32,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial child loader answer"},{"type":"text","text":"Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} failed before it finished.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session-log-deepseek/delivery-accepted","seq":33,"time":0,"data":{"sessionId":"{{sessionId}}","sessionFormatVersion":4,"throughSeq":32}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":34,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":35,"time":0,"data":{"turn":1,"step":3}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":36,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/session.v4.jsonl b/snapshots/sdk/subagent-dsh-sdk-diagnostic/session.v4.jsonl index d4a207876b..6574c7b4e3 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/session.v4.jsonl +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/session.v4.jsonl @@ -15,26 +15,24 @@ {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":12}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dsh_sdk_first","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe first DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1789820381724,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789820381724,"index":0,"dt":[],"id":"call_dsh_sdk_first","name":"subagent_dsh_sdk","args":["{\"description\":\"Observe first DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"]},{"type":"chunk","time":1789820381724,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_first","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe first DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}}},{"type":"chunk","time":1789820381724,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1789820381724,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_dsh_sdk_first","name":"subagent_dsh_sdk","arguments":"{\"description\":\"Observe first DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:1}}","childCreatedAt":1789820382130,"mode":"one-shot","label":"Observe first DSH SDK failure","external":"pending"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:1}}","childCreatedAt":1789820382130,"mode":"one-shot","label":"Observe first DSH SDK failure","external":"error"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:1}}","childCreatedAt":1789820382130,"mode":"one-shot","label":"Observe first DSH SDK failure","external":true}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{id:1}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial child loader answer"},{"type":"text","text":"Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:1}} failed before it finished.","senderSessionId":"{{id:1}}"},"role":"user","id":"{{message:5}}"}]}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dsh_sdk_first"},"toolCallId":"call_dsh_sdk_first","content":[{"type":"text","text":"started subagent {{id:1}}"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{id:1}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial child loader answer"},{"type":"text","text":"Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:1}} failed before it finished.","senderSessionId":"{{id:1}}"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":23}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":22}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_dsh_sdk_second","name":"subagent_dsh_sdk_second","arguments":"{\"description\":\"Observe second DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1789820382241,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789820382241,"index":0,"dt":[],"id":"call_dsh_sdk_second","name":"subagent_dsh_sdk_second","args":["{\"description\":\"Observe second DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"]},{"type":"chunk","time":1789820382241,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_second","name":"subagent_dsh_sdk_second","arguments":"{\"description\":\"Observe second DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}}},{"type":"chunk","time":1789820382241,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1789820382241,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_dsh_sdk_second","name":"subagent_dsh_sdk_second","arguments":"{\"description\":\"Observe second DSH SDK failure\",\"prompt\":\"Return the scripted DSH SDK failure.\"}"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:2}}","childCreatedAt":1789820382616,"mode":"one-shot","label":"Observe second DSH SDK failure","external":"pending"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:2}}","childCreatedAt":1789820382616,"mode":"one-shot","label":"Observe second DSH SDK failure","external":"error"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:2}}","childCreatedAt":1789820382616,"mode":"one-shot","label":"Observe second DSH SDK failure","external":true}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{id:2}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial child loader answer"},{"type":"text","text":"Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:2}} failed before it finished.","senderSessionId":"{{id:2}}"},"role":"user","id":"{{message:8}}"}]}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dsh_sdk_second"},"toolCallId":"call_dsh_sdk_second","content":[{"type":"text","text":"started subagent {{id:2}}"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[26],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_dsh_sdk_second"},"toolCallId":"call_dsh_sdk_second","content":[{"type":"text","text":"started subagent {{id:2}}"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{id:2}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial child loader answer"},{"type":"text","text":"Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:2}} failed before it finished.","senderSessionId":"{{id:2}}"},"role":"user","id":"{{message:8}}"},"surfaceOp":"append"} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":34}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":32}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1789820382721,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1789820382721,"index":0,"dt":[],"texts":["PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"]},{"type":"chunk","time":1789820382721,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}}},{"type":"chunk","time":1789820382721,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1789820382721,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/notifications.expected.jsonl b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/notifications.expected.jsonl index bfd4c2012b..c04d0cbc81 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/notifications.expected.jsonl +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/notifications.expected.jsonl @@ -11,15 +11,14 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":13,"time":0,"data":{"title":"Delegate once using the requested","messageSeqs":[9],"source":{"kind":"fallback"}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call-delegate","name":"subagent","args":["{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":16,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"route probe","external":"pending"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"route probe","external":"completed"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":18,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished. It cannot receive follow-up messages."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished. It cannot receive follow-up messages.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":19,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call-delegate"},"toolCallId":"call-delegate","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":21,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":22,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished. It cannot receive follow-up messages."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished. It cannot receive follow-up messages.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":26,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":16,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"route probe","external":true}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":17,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished. It cannot receive follow-up messages."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished. It cannot receive follow-up messages.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call-delegate"},"toolCallId":"call-delegate","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":22,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished. It cannot receive follow-up messages."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished. It cannot receive follow-up messages.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":25,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/session.v4.jsonl b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/session.v4.jsonl index df57499cd7..1bf347aa7d 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/session.v4.jsonl +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/session.v4.jsonl @@ -15,8 +15,7 @@ {"type":"session/title","data":{"title":"Delegate once using the requested","messageSeqs":[9],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1789820383205,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789820383205,"index":0,"dt":[],"id":"call-delegate","name":"subagent","args":["{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"]},{"type":"chunk","time":1789820383205,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}},{"type":"chunk","time":1789820383205,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1789820383205,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:1}}","childCreatedAt":1789820383554,"mode":"one-shot","label":"route probe","external":"pending"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:1}}","childCreatedAt":1789820383554,"mode":"one-shot","label":"route probe","external":"completed"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:1}}","childCreatedAt":1789820383554,"mode":"one-shot","label":"route probe","external":true}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{id:1}} finished. It cannot receive follow-up messages."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:1}} finished. It cannot receive follow-up messages.","senderSessionId":"{{id:1}}"},"role":"user","id":"{{message:5}}"}]}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call-delegate"},"toolCallId":"call-delegate","content":[{"type":"text","text":"started subagent {{id:1}}"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} diff --git a/snapshots/session/product-subagent-result-diagnostic/session.v4.jsonl b/snapshots/session/product-subagent-result-diagnostic/session.v4.jsonl index d62f659721..3f0db51e96 100644 --- a/snapshots/session/product-subagent-result-diagnostic/session.v4.jsonl +++ b/snapshots/session/product-subagent-result-diagnostic/session.v4.jsonl @@ -14,8 +14,7 @@ {"type":"session/title","data":{"title":"Observe four diagnostic failures with","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1789820716331,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789820716331,"index":0,"dt":[],"id":"call_claude_foreground","name":"subagent_codex","args":["{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\"}"]},{"type":"chunk","time":1789820716331,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\"}"}}},{"type":"chunk","time":1789820716331,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1789820716331,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\"}"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:1}}","childCreatedAt":1789820716338,"mode":"one-shot","label":"Observe Claude foreground diagnostic","external":"pending"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:1}}","childCreatedAt":1789820716338,"mode":"one-shot","label":"Observe Claude foreground diagnostic","external":"error"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:1}}","childCreatedAt":1789820716338,"mode":"one-shot","label":"Observe Claude foreground diagnostic","external":true}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{id:1}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial assistant text"},{"type":"text","text":"Product subagent failure (product: Claude Code; stage: query-run; category: limit)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:1}} failed before it finished.","senderSessionId":"{{id:1}}"},"role":"user","id":"{{message:5}}"}]}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_claude_foreground"},"toolCallId":"call_claude_foreground","content":[{"type":"text","text":"started subagent {{id:1}}"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -24,30 +23,27 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{id:1}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial assistant text"},{"type":"text","text":"Product subagent failure (product: Claude Code; stage: query-run; category: limit)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:1}} failed before it finished.","senderSessionId":"{{id:1}}"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1789820716352,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789820716353,"index":0,"dt":[],"id":"call_claude_background","name":"subagent_codex","args":["{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\"}"]},{"type":"chunk","time":1789820716353,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\"}"}}},{"type":"chunk","time":1789820716353,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1789820716353,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\"}"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:2}}","childCreatedAt":1789820716357,"mode":"one-shot","label":"Observe Claude background diagnostic","external":"pending"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:2}}","childCreatedAt":1789820716357,"mode":"one-shot","label":"Observe Claude background diagnostic","external":"error"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:2}}","childCreatedAt":1789820716357,"mode":"one-shot","label":"Observe Claude background diagnostic","external":true}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{id:2}} failed before it finished."},{"type":"text","text":"It left no closing message."},{"type":"text","text":"Product subagent failure (product: Claude Code; stage: query-run; category: limit)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:2}} failed before it finished.","senderSessionId":"{{id:2}}"},"role":"user","id":"{{message:8}}"}]}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_claude_background"},"toolCallId":"call_claude_background","content":[{"type":"text","text":"started subagent {{id:2}}"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_claude_background"},"toolCallId":"call_claude_background","content":[{"type":"text","text":"started subagent {{id:2}}"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{id:2}} failed before it finished."},{"type":"text","text":"It left no closing message."},{"type":"text","text":"Product subagent failure (product: Claude Code; stage: query-run; category: limit)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:2}} failed before it finished.","senderSessionId":"{{id:2}}"},"role":"user","id":"{{message:8}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:10}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1789820716371,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789820716371,"index":0,"dt":[],"id":"call_codex_foreground","name":"subagent_codex","args":["{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\"}"]},{"type":"chunk","time":1789820716371,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\"}"}}},{"type":"chunk","time":1789820716371,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1789820716371,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\"}"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:3}}","childCreatedAt":1789820716376,"mode":"one-shot","label":"Observe Codex foreground diagnostic","external":"pending"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:3}}","childCreatedAt":1789820716376,"mode":"one-shot","label":"Observe Codex foreground diagnostic","external":"error"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:3}}","childCreatedAt":1789820716376,"mode":"one-shot","label":"Observe Codex foreground diagnostic","external":true}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{id:3}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial assistant text"},{"type":"text","text":"Product subagent failure (product: Codex; stage: turn; category: transport; HTTP status: 503)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:3}} failed before it finished.","senderSessionId":"{{id:3}}"},"role":"user","id":"{{message:11}}"}]}} -{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"call_codex_foreground"},"toolCallId":"call_codex_foreground","content":[{"type":"text","text":"started subagent {{id:3}}"}],"isError":false,"id":"{{message:12}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"call_codex_foreground"},"toolCallId":"call_codex_foreground","content":[{"type":"text","text":"started subagent {{id:3}}"}],"isError":false,"id":"{{message:12}}"}},"sourceEventSeqs":[32],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":4}} {"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{id:3}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"partial assistant text"},{"type":"text","text":"Product subagent failure (product: Codex; stage: turn; category: transport; HTTP status: 503)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:3}} failed before it finished.","senderSessionId":"{{id:3}}"},"role":"user","id":"{{message:11}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:13}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1789820716388,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789820716388,"index":0,"dt":[],"id":"call_codex_background","name":"subagent_codex","args":["{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\"}"]},{"type":"chunk","time":1789820716388,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\"}"}}},{"type":"chunk","time":1789820716388,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1789820716388,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\"}"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:4}}","childCreatedAt":1789820716392,"mode":"one-shot","label":"Observe Codex background diagnostic","external":"pending"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:4}}","childCreatedAt":1789820716392,"mode":"one-shot","label":"Observe Codex background diagnostic","external":"error"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:4}}","childCreatedAt":1789820716392,"mode":"one-shot","label":"Observe Codex background diagnostic","external":true}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{id:4}} failed before it finished."},{"type":"text","text":"It left no closing message."},{"type":"text","text":"Product subagent failure (product: Codex; stage: turn; category: transport; HTTP status: 503)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:4}} failed before it finished.","senderSessionId":"{{id:4}}"},"role":"user","id":"{{message:14}}"}]}} -{"type":"tool/result","data":{"turn":1,"step":4,"message":{"role":"tool","source":{"kind":"tool","callId":"call_codex_background"},"toolCallId":"call_codex_background","content":[{"type":"text","text":"started subagent {{id:4}}"}],"isError":false,"id":"{{message:15}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"role":"tool","source":{"kind":"tool","callId":"call_codex_background"},"toolCallId":"call_codex_background","content":[{"type":"text","text":"started subagent {{id:4}}"}],"isError":false,"id":"{{message:15}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":5}} diff --git a/snapshots/session/subagent-acp-diagnostic/session.v4.jsonl b/snapshots/session/subagent-acp-diagnostic/session.v4.jsonl index 5c2f3a7e01..6a6f9f5b15 100644 --- a/snapshots/session/subagent-acp-diagnostic/session.v4.jsonl +++ b/snapshots/session/subagent-acp-diagnostic/session.v4.jsonl @@ -14,8 +14,7 @@ {"type":"session/title","data":{"title":"Observe the ACP diagnostic twice","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_acp_first","name":"subagent_acp","arguments":"{\"description\":\"Observe first ACP failure\",\"prompt\":\"Return the scripted ACP failure.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1789820717257,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789820717257,"index":0,"dt":[],"id":"call_acp_first","name":"subagent_acp","args":["{\"description\":\"Observe first ACP failure\",\"prompt\":\"Return the scripted ACP failure.\"}"]},{"type":"chunk","time":1789820717257,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_acp_first","name":"subagent_acp","arguments":"{\"description\":\"Observe first ACP failure\",\"prompt\":\"Return the scripted ACP failure.\"}"}}},{"type":"chunk","time":1789820717257,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1789820717257,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_acp_first","name":"subagent_acp","arguments":"{\"description\":\"Observe first ACP failure\",\"prompt\":\"Return the scripted ACP failure.\"}"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:1}}","childCreatedAt":1789820717386,"mode":"one-shot","label":"Observe first ACP failure","external":"pending"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:1}}","childCreatedAt":1789820717386,"mode":"one-shot","label":"Observe first ACP failure","external":"aborted"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:1}}","childCreatedAt":1789820717386,"mode":"one-shot","label":"Observe first ACP failure","external":true}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{id:1}} was stopped before it finished."},{"type":"text","text":"It left no closing message."},{"type":"text","text":"ACP unattended decision (policy: reject; request: execute; decision: denied)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:1}} was stopped before it finished.","senderSessionId":"{{id:1}}"},"role":"user","id":"{{message:5}}"}]}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_acp_first"},"toolCallId":"call_acp_first","content":[{"type":"text","text":"started subagent {{id:1}}"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -24,10 +23,9 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{id:1}} was stopped before it finished."},{"type":"text","text":"It left no closing message."},{"type":"text","text":"ACP unattended decision (policy: reject; request: execute; decision: denied)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:1}} was stopped before it finished.","senderSessionId":"{{id:1}}"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_acp_second","name":"subagent_acp","arguments":"{\"description\":\"Observe second ACP failure\",\"prompt\":\"Return the scripted ACP failure.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1789820717420,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789820717420,"index":0,"dt":[],"id":"call_acp_second","name":"subagent_acp","args":["{\"description\":\"Observe second ACP failure\",\"prompt\":\"Return the scripted ACP failure.\"}"]},{"type":"chunk","time":1789820717420,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_acp_second","name":"subagent_acp","arguments":"{\"description\":\"Observe second ACP failure\",\"prompt\":\"Return the scripted ACP failure.\"}"}}},{"type":"chunk","time":1789820717420,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1789820717420,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_acp_second","name":"subagent_acp","arguments":"{\"description\":\"Observe second ACP failure\",\"prompt\":\"Return the scripted ACP failure.\"}"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:2}}","childCreatedAt":1789820717542,"mode":"one-shot","label":"Observe second ACP failure","external":"pending"}} -{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:2}}","childCreatedAt":1789820717542,"mode":"one-shot","label":"Observe second ACP failure","external":"aborted"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{id:2}}","childCreatedAt":1789820717542,"mode":"one-shot","label":"Observe second ACP failure","external":true}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{id:2}} was stopped before it finished."},{"type":"text","text":"It left no closing message."},{"type":"text","text":"ACP unattended decision (policy: reject; request: execute; decision: denied)"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{id:2}} was stopped before it finished.","senderSessionId":"{{id:2}}"},"role":"user","id":"{{message:8}}"}]}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_acp_second"},"toolCallId":"call_acp_second","content":[{"type":"text","text":"started subagent {{id:2}}"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_acp_second"},"toolCallId":"call_acp_second","content":[{"type":"text","text":"started subagent {{id:2}}"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":3}} From ef88d6a2772a65b8550bd5bcf116cb7eb10f28d1 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 10:37:26 +0800 Subject: [PATCH 024/402] refactor(subagent): deliver activation results after teardown --- ...-17-unified-subagent-activations.i18n.yaml | 4 +- ...2026-09-17-unified-subagent-activations.md | 2 +- ...6-09-17-unified-subagent-activations.zh.md | 2 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 7 +- docs/subsystems/subagent.zh.md | 7 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 4 +- packages/subagent/subagent/README.zh.md | 4 +- .../subagent/src/continuation-activation.ts | 79 +++++++------------ packages/subagent/subagent/src/types.ts | 7 +- .../subagent/tests/activation-presets.spec.ts | 11 ++- .../subagent/tests/continuation.spec.ts | 65 +++++++++++++++ .../tests/external-activation.spec.ts | 53 ++++++++++--- .../subagent/tests/external-lifecycle.spec.ts | 2 +- .../subagent/tests/structured.spec.ts | 12 +-- 16 files changed, 177 insertions(+), 90 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.i18n.yaml index b94acc03f3..61fb198175 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.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-09-17-unified-subagent-activations.md -2026-09-17-unified-subagent-activations.md: ae663e7eab0673f6f6d7a43cd5ca4656e3f757e1 -2026-09-17-unified-subagent-activations.zh.md: 2a2a243f6c13e13bd4f9b0b59bcf5d4218077c07 +2026-09-17-unified-subagent-activations.md: 197f8c20ebcc11014d4710cbb7f0c9335b57d53b +2026-09-17-unified-subagent-activations.zh.md: e774f7a7d149317a96b3d7e39026cfb2f180fafe diff --git a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md index ae663e7eab..197f8c20eb 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md +++ b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md @@ -10,7 +10,7 @@ Local one-shot execution and continuable residency separately owned child creati ## Decision -`startActivation()` is the consumer entry point for every provider. A published handle exposes child identity, a result promise, and disposal of that exact activation. The caller signal cancels unpublished creation only. Local results wait for pending input and owned descendants before closing admission, so descendant replies can contribute to the final answer. External result readiness remains separate from process cleanup; callers await disposal when they need resource release. +`startActivation()` is the consumer entry point for every provider. A published handle exposes child identity, a result promise, and disposal of that exact activation. The caller signal cancels unpublished creation only. Local results wait for pending input and owned descendants before closing admission, so descendant replies can contribute to the final answer. Both local and external result promises settle after cleanup and notifications. Parent waits use that same completion promise; a captured execution result survives cleanup failure, which disposal reports separately. Workflow consumers already wait for cleanup before continuing, so activation ownership needs no independent result-readiness and release signals. Local spawn and fork providers contribute only `prepareContinuable()` data; the subagent service owns Agent creation and residency. The standalone in-process driver package is removed. Structured tools, validation, instructions, and terminal guards attach to one activation and close with it. After capture, that activation rejects further input; cold resume reconstructs an ordinary conversation without the previous schema. diff --git a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md index 2a2a243f6c..e774f7a7d1 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`startActivation()` 是所有提供方的消费者入口。已发布句柄暴露子级身份、结果 promise 和对应 activation 的资源释放。调用方信号仅取消未发布的创建。本地结果等待待处理输入与所拥有的后代结束后才关闭准入,使后代回复能够进入最终答案。外部结果就绪仍与进程清理分离;需要资源释放的调用方会等待 disposal。 +`startActivation()` 是所有提供方的消费者入口。已发布句柄暴露子级身份、结果 promise 和对应 activation 的资源释放。调用方信号仅取消未发布的创建。本地结果等待待处理输入与所拥有的后代结束后才关闭准入,使后代回复能够进入最终答案。本地与外部结果 promise 都在清理和通知结束后完成。父级使用同一个完成 promise 等待;清理失败不覆盖已捕获的执行结果,由 disposal 单独报告。工作流消费者本就等待清理后再继续,因此 activation 所有权不需要独立的结果就绪与释放信号。 本地 spawn 和 fork 提供方只贡献 `prepareContinuable()` 数据,由 subagent 服务拥有 Agent 创建与驻留。独立进程内驱动器包被移除。结构化工具、校验、指令与终止保护附着于一次 activation,并随其关闭。捕获后该 activation 拒绝继续输入;冷恢复重建普通对话,不携带此前的 schema。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 952708e10a..f08ef88492 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/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/subsystems/subagent.md -subagent.md: 03172744ad388322fb4b292317aeec01acab6de9 -subagent.zh.md: 2c1399eeb697480ebfceef86f76b0cda5fc912d6 +subagent.md: 1467982e38cab81c9d16f321ad569bc2bf29eec3 +subagent.zh.md: 50e8bfa577aac02280da4c4504f479506a6c3255 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 03172744ad..1467982e38 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -49,9 +49,12 @@ interface SubagentActivation { readonly childId: SessionId /** Accepted inbox message, when the backend has a local inbox. */ readonly messageId?: MessageId - /** Execution result, independently of eventual resource release. */ + /** + * Execution result after teardown settles and notifications are sent; capture failures reject. + * Teardown failures are reported by dispose() without replacing a captured result. + */ readonly result: Promise - /** Stop and release this activation and its owned descendants. */ + /** Stop and release this activation and its owned descendants; rejects on teardown failure. */ dispose(): Promise } ``` diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 2c1399eeb6..50e8bfa577 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -49,9 +49,12 @@ interface SubagentActivation { readonly childId: SessionId /** Accepted inbox message, when the backend has a local inbox. */ readonly messageId?: MessageId - /** Execution result, independently of eventual resource release. */ + /** + * Execution result after teardown settles and notifications are sent; capture failures reject. + * Teardown failures are reported by dispose() without replacing a captured result. + */ readonly result: Promise - /** Stop and release this activation and its owned descendants. */ + /** Stop and release this activation and its owned descendants; rejects on teardown failure. */ dispose(): Promise } ``` diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index fa70da8bb8..878603d783 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: 2316002d7607a5eccad1b5acfe59822722b393cc -README.zh.md: 3568bbb3dd69def11b29b08290ff2c9d88c278f7 +README.md: 4685ffcf843b7159b479a294cc1084778c5c2fbb +README.zh.md: 081b18bbaecf9db1c897d1cc99feecec804760e6 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 2316002d76..4685ffcf84 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -64,7 +64,7 @@ Every exact live Agent can use `sendMessage()` with a direct continuable child; ### Failure and recovery -Unsupported provider capabilities reject creation. The caller signal cancels only unpublished work; after publication, `dispose()` cancels the exact activation and waits for its descendants and resources. `result` carries output, optional structured data, and a stop reason; infrastructure failures may reject it. Callers that require resource cleanup also await `dispose()`. +Unsupported provider capabilities reject creation. The caller signal cancels only unpublished work; after publication, `dispose()` cancels the exact activation and waits for its descendants and resources. `result` settles after teardown and notifications, carrying output, optional structured data, and a stop reason; capture failures reject it after teardown. Cleanup failures reject `dispose()` without replacing a captured execution result. ----- @@ -108,7 +108,7 @@ The service validates requested capabilities before creation. Local providers su ### Activation settlement -The manager reserves child identity and capacity, materializes a local Agent or external execution, and accepts the initial task. Local `result` becomes ready after Agent activity, pending input, and owned descendants finish, with activity revalidated after the final Session flush. The manager publishes this result while closing admission, before releasing the local handle. External results can become ready before process cleanup finishes. Closing admission and disposing the handle prevent late work from entering a released Agent. Parent delivery emits its notice after settlement; caller delivery leaves collection to the awaiting workflow. Headless hosts repeat `agent.whenIdle()` and `waitForChildren(agent)` until no child work remains, so completion notices can produce the final parent answer. +The manager reserves child identity and capacity, materializes a local Agent or external execution, and accepts the initial task. Local admission closes after Agent activity, pending input, and owned descendants finish, with activity revalidated after the final Session flush. Both local and external activations deliver their result after handle cleanup, capacity release, and settlement notifications. Parent waits observe the same completion promise even when execution fails. Closing admission and disposing the handle prevent late work from entering a released Agent. Parent delivery emits its notice after settlement; caller delivery leaves collection to the awaiting workflow. Headless hosts repeat `agent.whenIdle()` and `waitForChildren(agent)` until no child work remains, so completion notices can produce the final parent answer. Successful local and external creation appends a `subagent/catalog` fact to the parent Session. External entries use `external: true` to identify children without a local Session. Catalog entries record membership once at creation and carry no execution status. Complete results go to the caller or the parent completion notice. Projections exclude inherited facts. Direct lists read one parent projection; descendant lists enumerate Session-backed children through corpus identity. Catalog payload v0 records known modes; v1 also accepts unknown mode. Readers support both versions. Historical migration appends a v1 `subagent/catalog` from a readable child header when its descriptor is unavailable; normal creation retains v0. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 3568bbb3dd..081b18bbae 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" ### 失败与恢复 -不受提供方支持的能力会使创建失败。调用方信号仅取消尚未发布的工作;发布后通过 `dispose()` 取消该次 activation,并等待其后代和资源释放。`result` 携带输出、可选结构化数据与停止原因,基础设施故障可能使其拒绝。需要完成资源清理的调用方还应等待 `dispose()`。 +不受提供方支持的能力会使创建失败。调用方信号仅取消尚未发布的工作;发布后通过 `dispose()` 取消该次 activation,并等待其后代和资源释放。`result` 在清理与通知结束后完成,携带输出、可选结构化数据与停止原因;结果捕获故障在清理后使其拒绝。清理失败使 `dispose()` 拒绝,不覆盖已捕获的执行结果。 ----- @@ -108,7 +108,7 @@ kind: "package-reference" ### Activation 结算 -管理器预留子级身份与容量,创建本地 Agent 或外部执行,并接受初始任务。本地 Agent 活动、待处理输入及所拥有的后代均结束后,管理器在最终 Session flush 后重新验证活动状态,使 `result` 就绪。管理器在关闭准入时发布结果,随后释放本地句柄。外部结果可以早于进程清理完成而就绪。关闭准入并释放句柄可防止迟到工作进入已释放的 Agent。父级投递在结算后发送通知;调用方投递由等待中的工作流收集。Headless 宿主交替等待 `agent.whenIdle()` 与 `waitForChildren(agent)`,直至没有子级工作,让完成通知可驱动父级生成最终答案。 +管理器预留子级身份与容量,创建本地 Agent 或外部执行,并接受初始任务。本地 Agent 活动、待处理输入及所拥有的后代均结束后,管理器在最终 Session flush 后重新验证活动状态并关闭准入。本地与外部 activation 都在句柄清理、容量归还及结算通知结束后交付结果。父级通过同一个完成 promise 等待,执行失败也不会使等待提前结束。关闭准入并释放句柄可防止迟到工作进入已释放的 Agent。父级投递在结算后发送通知;调用方投递由等待中的工作流收集。Headless 宿主交替等待 `agent.whenIdle()` 与 `waitForChildren(agent)`,直至没有子级工作,让完成通知可驱动父级生成最终答案。 本地与外部创建成功时,都向父 Session 追加 `subagent/catalog` 事实。外部条目以 `external: true` 标记没有本地 Session 的子级。目录仅在创建时记录一次成员关系,不携带执行状态。完整结果交给调用方或父级完成通知。投影排除继承事实。直接列表读取一个父级投影;后代列表通过语料中的身份枚举具有 Session 的子级。Catalog 载荷 v0 记录已知模式,v1 还接受未知模式,读取器支持两版。历史迁移在 descriptor 不可用时根据可读子 header 追加 v1 `subagent/catalog`;正常创建保留 v0。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。 diff --git a/packages/subagent/subagent/src/continuation-activation.ts b/packages/subagent/subagent/src/continuation-activation.ts index e345e61ff8..81980ba190 100644 --- a/packages/subagent/subagent/src/continuation-activation.ts +++ b/packages/subagent/subagent/src/continuation-activation.ts @@ -77,7 +77,6 @@ export interface Activation { readonly parent: Agent readonly delivery: 'parent' | 'caller' readonly result: PromiseWithResolvers - readonly released: PromiseWithResolvers /** Published synchronously before teardown; subsequent callers share this transaction. */ closing: Promise | undefined /** @@ -408,7 +407,7 @@ export class ContinuableActivationRegistry { if (pending.length === 0 && children.length === 0) return false await Promise.all([ ...pending.map(item => item.settled), - ...children.map(item => item.released.promise), + ...children.map(item => item.result.promise.catch(() => undefined)), ]) return true } @@ -598,7 +597,7 @@ export class ContinuableActivationRegistry { * @returns the shared close transaction. */ dispose(activation: Activation): Promise { - return this.close(activation, () => this.finishDisposal(activation)) + return this.close(activation, () => this.finishDisposal(activation, true)) } /** Close admission synchronously and share one release, including startup rollback. */ @@ -748,7 +747,6 @@ export class ContinuableActivationRegistry { parent, delivery: inputs.delivery ?? 'parent', result: Promise.withResolvers(), - released: Promise.withResolvers(), closing: undefined, ancestry: new WeakSet(driver.agent === undefined ? parentLineage : [driver.agent, ...parentLineage]), ownedChildren: new Set(), @@ -769,7 +767,7 @@ export class ContinuableActivationRegistry { } catch (error: unknown) { /* 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) + await this.rollbackUnpublished(activation, error).catch(() => undefined) throw error } return activation @@ -786,7 +784,7 @@ export class ContinuableActivationRegistry { } /** Release an Activation whose start edge was not published. */ - private rollbackUnpublished(activation: Activation): Promise { + private rollbackUnpublished(activation: Activation, error: unknown): Promise { return this.close(activation, async () => { try { await activation.driver.dispose() @@ -794,7 +792,7 @@ export class ContinuableActivationRegistry { this.resident.delete(activation.childId) activation.releaseSlot() this.releaseOwnership(activation.childId) - activation.released.resolve() + activation.result.reject(error) } }) } @@ -832,16 +830,6 @@ export class ContinuableActivationRegistry { const idleObservation = activation.poke await activation.driver.whenIdle() if (activation.closing !== undefined) return - if (activation.driver.kind === 'external') { - try { - activation.result.resolve(activation.driver.capture()) - } catch (error: unknown) { - activation.result.reject(error) - // Teardown captures the terminal failure and releases the activation. - await this.dispose(activation).catch(() => undefined) - return - } - } const readiness = await this.locks.run(activation.childId, () => Promise.resolve( this.settlementState(activation, idleObservation), )) @@ -860,18 +848,9 @@ export class ContinuableActivationRegistry { if (activation.driver.version !== finalSeq) { return Promise.resolve('retry') } - let result: SubagentResult - try { - result = activation.driver.capture() - } catch (error: unknown) { - activation.result.reject(error) - return Promise.resolve({ done: this.dispose(activation) }) - } - // Result publication and admission closure share the final idle claim. let done!: Promise if (!activation.driver.closeWhenIdle(() => { - activation.result.resolve(result) - done = this.close(activation, () => this.finishDisposal(activation, result)) + done = this.close(activation, () => this.finishDisposal(activation, false)) })) { return Promise.resolve('retry') } @@ -908,25 +887,26 @@ export class ContinuableActivationRegistry { } /** Propagate stop synchronously, then finish the child-first release. */ - private async finishDisposal(activation: Activation, settledResult?: SubagentResult): Promise { + private async finishDisposal(activation: Activation, stop: boolean): Promise { this.wake(activation) const { childId } = activation const failures: SubagentError[] = [] - let result: SubagentResult = settledResult ?? { output: [], stopReason: 'error' } + let result: SubagentResult = { output: [], stopReason: 'error' } + let resultFailure: { error: unknown } | undefined const child = activation.driver.agent const includeOutput = child === undefined || !isAdjacentAgentSendMessageTool(child.ctx.get('tools')?.get('send_message', child)) const detail = (error: unknown): string => activation.driver.agent === undefined ? failureMessage(error) : errorChain(error) - if (settledResult === undefined) { - activation.driver.cancel('parent') - const idle = activation.driver.whenIdle() - const children = [...activation.ownedChildren] - .map(child => this.resident.get(child)) - .filter((child): child is Activation => child !== undefined) - const childDisposals = children.map(child => this.dispose(child)) - try { + try { + if (stop) { + activation.driver.cancel('parent') + const idle = activation.driver.whenIdle() + const children = [...activation.ownedChildren] + .map(child => this.resident.get(child)) + .filter((child): child is Activation => child !== undefined) + const childDisposals = children.map(child => this.dispose(child)) const childFailures = await Promise.all(childDisposals.map(async (disposal) => { try { await disposal @@ -944,14 +924,15 @@ export class ContinuableActivationRegistry { } await idle await this.flushFinalState(activation) - result = activation.driver.capture() - } catch (error: unknown) { - failures.push(new SubagentError( - `subagent "${childId}" activation teardown failed: ${detail(error)}`, - 'ACTIVATION_TEARDOWN_FAILED', - { cause: error }, - )) } + result = activation.driver.capture() + } catch (error: unknown) { + resultFailure = { error } + failures.push(new SubagentError( + `subagent "${childId}" activation teardown failed: ${detail(error)}`, + 'ACTIVATION_TEARDOWN_FAILED', + { cause: error }, + )) } try { await activation.driver.dispose() @@ -976,12 +957,12 @@ export class ContinuableActivationRegistry { } this.resident.delete(childId) activation.releaseSlot() - if (failure !== undefined) result = { output: [], stopReason: 'error' } - activation.result.resolve(result) - this.notifySettlement(activation, result, includeOutput) + const terminal: SubagentResult = failure === undefined ? result : { output: [], stopReason: 'error' } + this.notifySettlement(activation, terminal, includeOutput) this.releaseOwnership(childId) - activation.observer.settle(result) - activation.released.resolve() + activation.observer.settle(terminal) + if (resultFailure === undefined) activation.result.resolve(result) + else activation.result.reject(resultFailure.error) if (failure !== undefined) throw failure } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index b0a4a8bf97..5a2e24f7b5 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -49,9 +49,12 @@ export interface SubagentActivation { readonly childId: SessionId /** Accepted inbox message, when the backend has a local inbox. */ readonly messageId?: MessageId - /** Execution result, independently of eventual resource release. */ + /** + * Execution result after teardown settles and notifications are sent; capture failures reject. + * Teardown failures are reported by dispose() without replacing a captured result. + */ readonly result: Promise - /** Stop and release this activation and its owned descendants. */ + /** Stop and release this activation and its owned descendants; rejects on teardown failure. */ dispose(): Promise } diff --git a/packages/subagent/subagent/tests/activation-presets.spec.ts b/packages/subagent/subagent/tests/activation-presets.spec.ts index 2a6942d952..80876f1630 100644 --- a/packages/subagent/subagent/tests/activation-presets.spec.ts +++ b/packages/subagent/subagent/tests/activation-presets.spec.ts @@ -66,14 +66,13 @@ function spawnRequest(parent: Agent) { describe('a child agent composed in-process', () => { it('reaches the model with its parent\'s preset tools', async () => { - const { ctx, adapter, parent } = await setupPresetHost() + const { adapter, parent } = await setupPresetHost() const run = await startPreparedActivation(spawnRequest(parent), {}) await run.result const childRequest = adapter.requests.at(-1) expect(childRequest?.tools?.map(tool => tool.name)).toEqual(['preset_only']) - expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only']) await run.dispose() }) @@ -102,7 +101,7 @@ describe('a child agent composed in-process', () => { }) it('honours a tool filter over the preset tools it inherited', async () => { - const { ctx, parent } = await setupPresetHost() + const { adapter, parent } = await setupPresetHost() const run = await startPreparedActivation( { ...spawnRequest(parent), toolFilter: { deny: ['preset_only'] } }, @@ -112,12 +111,12 @@ describe('a child agent composed in-process', () => { // The capability filter is the only thing bounding a delegated child, and // every tool it can name now arrives from the preset rather than the host. - expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual([]) + expect((adapter.requests.at(-1)?.tools ?? []).map(tool => tool.name)).toEqual([]) await run.dispose() }) it('follows a parent that switched preset while blank', async () => { - const { ctx, parent } = await setupPresetHost() + const { ctx, adapter, parent } = await setupPresetHost() // A DIFFERENT preset, so the assertion below distinguishes reading the // parent's live scope chain from reading its creation header — re-linking // to the same id would pass either way. @@ -126,7 +125,7 @@ describe('a child agent composed in-process', () => { const run = await startPreparedActivation(spawnRequest(parent), {}) await run.result - expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['reviewing_only']) + expect((adapter.requests.at(-1)?.tools ?? []).map(tool => tool.name)).toEqual(['reviewing_only']) expect(run.localAgent?.session.header.agentPreset).toBe('reviewing') await run.dispose() }) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index a123427b18..a1098de98a 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -2245,6 +2245,45 @@ describe('continuable review regressions', () => { expect(ends[0]!.stopReason).toBe('refusal') }) + it('delivers a local result after handle cleanup and parent notifications', async () => { + const turn = Promise.withResolvers() + const cleaning = Promise.withResolvers() + const cleanup = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: turn.promise }]) + const { ctx, parent } = await setupWith(adapter) + const followup = vi.spyOn(parent, 'followup') + const steer = vi.spyOn(parent, 'steer') + const ended = vi.fn() + ctx.on('subagent/end', ended) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) + const activation = continuationActivations(ctx).get(started.childId)! + const realDispose = activation.driver.dispose.bind(activation.driver) + activation.driver.dispose = async () => { + cleaning.resolve(undefined) + await cleanup.promise + await realDispose() + } + const observed = vi.fn() + void started.result.then(observed, observed) + const waiting = ctx.subagents.waitForChildren(parent) + turn.resolve(undefined) + try { + await cleaning.promise + expect(observed).not.toHaveBeenCalled() + expect(ended).not.toHaveBeenCalled() + expect(followup).not.toHaveBeenCalled() + expect(steer).not.toHaveBeenCalled() + } finally { + cleanup.resolve(undefined) + await started.dispose() + } + await expect(started.result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(ended).toHaveBeenCalledTimes(1) + expect(followup.mock.calls.length + steer.mock.calls.length).toBe(1) + expect(continuationActivations(ctx).get(started.childId)).toBeUndefined() + await expect(waiting).resolves.toBe(true) + }) + it('reports handle-disposal failure on the terminal edge', async () => { const { ctx, parent } = await setup([textResponse('answer')]) const ends: SubagentRunEndInfo[] = [] @@ -2388,6 +2427,32 @@ describe('continuable review regressions', () => { await waitNoActivation(ctx, started.childId) }) + it('shares disposal when explicit close overtakes a queued idle settlement', async () => { + const turn = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: turn.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'caller' }) + const activation = continuationActivations(ctx).get(started.childId)! + const child = ctx.agents.get(started.childId)! + const disposed = vi.spyOn(activation.driver, 'dispose') + const ended = vi.fn() + ctx.on('subagent/end', ended) + const lock = await holdChildLock(ctx, started.childId) + try { + turn.resolve(undefined) + await child.whenIdle() + await started.dispose() + } finally { + lock.release() + await lock.held + } + await passSettlementCheck(ctx, started.childId) + await expect(started.result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(disposed).toHaveBeenCalledTimes(1) + expect(ended).toHaveBeenCalledTimes(1) + expect(continuationActivations(ctx).get(started.childId)).toBeUndefined() + }) + it('keeps a maintenance task that claimed the idle phase after whenIdle resolved', async () => { const release = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }]) diff --git a/packages/subagent/subagent/tests/external-activation.spec.ts b/packages/subagent/subagent/tests/external-activation.spec.ts index c31cfba74d..a0511ccf16 100644 --- a/packages/subagent/subagent/tests/external-activation.spec.ts +++ b/packages/subagent/subagent/tests/external-activation.spec.ts @@ -115,7 +115,7 @@ describe('external subagent activations', () => { }, ) - it('detaches accepted work from caller cancellation and exposes its result before cleanup finishes', async () => { + it('detaches accepted work from caller cancellation and waits for cleanup before delivering its result', async () => { const backend = execution() let providerSignal: AbortSignal | undefined const fixture = await setup(async ({ signal }) => { providerSignal = signal; return backend.run }) @@ -125,23 +125,57 @@ describe('external subagent activations', () => { expect(backend.dispose).not.toHaveBeenCalled() expect(fixture.ctx.sessions.get(activation.childId)).toBeUndefined() backend.result.resolve(completed) - await expect(activation.result).resolves.toEqual(completed) await backend.cleaning.promise + const observed = vi.fn() + void activation.result.then(observed, observed) + const waiting = fixture.ctx.subagents.waitForChildren(fixture.parent) + const waited = vi.fn() + void waiting.then(waited) const disposal = activation.dispose() expect(activation.dispose()).toBe(disposal) - let cleaned = false - void disposal.then(() => { cleaned = true }) - await Promise.resolve() - expect(cleaned).toBe(false) - expect(backend.dispose).toHaveBeenCalledTimes(1) - backend.cleanup.resolve(undefined) - await disposal + try { + await Promise.resolve() + expect(observed).not.toHaveBeenCalled() + expect(waited).not.toHaveBeenCalled() + expect(backend.dispose).toHaveBeenCalledTimes(1) + } finally { + backend.cleanup.resolve(undefined) + await disposal + } + await expect(activation.result).resolves.toEqual(completed) + await expect(waiting).resolves.toBe(true) await activation.dispose() expect(backend.dispose).toHaveBeenCalledTimes(1) expect(fixture.records()).toMatchObject([{ id: activation.childId, external: true }]) expect(fixture.parent.inbox.nextTurn).toEqual([]) }) + it('rejects a failed execution only after cleanup and lets parent waiting finish', async () => { + const backend = execution() + const fixture = await setup(async () => backend.run) + const activation = await fixture.start() + const error = new Error('backend execution failed') + const observed = vi.fn() + void activation.result.then(observed, observed) + const waiting = fixture.ctx.subagents.waitForChildren(fixture.parent) + const waited = vi.fn() + void waiting.then(waited) + backend.result.reject(error) + await backend.cleaning.promise + const disposal = activation.dispose().catch(() => undefined) + try { + await Promise.resolve() + expect(observed).not.toHaveBeenCalled() + expect(waited).not.toHaveBeenCalled() + } finally { + backend.cleanup.resolve(undefined) + await disposal + } + await expect(activation.result).rejects.toBe(error) + await expect(waiting).resolves.toBe(true) + await expect(fixture.ctx.subagents.waitForChildren(fixture.parent)).resolves.toBe(false) + }) + it('cancels the external process once when the receipt is repeatedly disposed', async () => { const backend = execution() const cancelled = vi.fn(() => { backend.result.resolve({ output: [], stopReason: 'aborted' }) }) @@ -203,7 +237,6 @@ describe('external subagent activations', () => { const fixture = await setup(async () => backend.run) const activation = await fixture.start() backend.result.resolve(completed) - await expect(activation.result).resolves.toEqual(completed) await backend.cleaning.promise const disposal = activation.dispose() const rejection = expect(disposal).rejects.toThrow('safe cleanup failure') diff --git a/packages/subagent/subagent/tests/external-lifecycle.spec.ts b/packages/subagent/subagent/tests/external-lifecycle.spec.ts index 343fb1ad09..485772ad33 100644 --- a/packages/subagent/subagent/tests/external-lifecycle.spec.ts +++ b/packages/subagent/subagent/tests/external-lifecycle.spec.ts @@ -107,11 +107,11 @@ describe('external activation ownership', () => { const fixture = await setup(() => Promise.resolve(run('failed-cleanup', result.promise, () => cleanup.promise))) const activation = await fixture.start() result.resolve(complete) - await expect(activation.result).resolves.toEqual(complete) const disposal = activation.dispose() const rejected = expect(disposal).rejects.toThrow('unknown teardown failure') cleanup.reject({ private: 'SECRET_TOKEN' }) await rejected + await expect(activation.result).resolves.toEqual(complete) expect((await disposal.catch((error: unknown) => error) as Error).message).not.toContain('SECRET_TOKEN') }) }) diff --git a/packages/subagent/subagent/tests/structured.spec.ts b/packages/subagent/subagent/tests/structured.spec.ts index 01b7d2a21e..13b176c5bc 100644 --- a/packages/subagent/subagent/tests/structured.spec.ts +++ b/packages/subagent/subagent/tests/structured.spec.ts @@ -197,7 +197,7 @@ describe('in-process structured output', () => { const result = await run.result expect(result.structured).toEqual({ answer: 5 }) expect(sideEffectRan).toBe(false) - const child = ctx.agents.get(run.id) + const child = run.localAgent const sideEffectResult = child?.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.source.callId === 'c2') expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.message.isError).toBe(true) @@ -494,7 +494,7 @@ describe('in-process structured output', () => { const result = await run.result expect(result.structured).toBeUndefined() expect(result.stopReason).toBe('error') - const child = ctx.agents.get(run.id) + const child = run.localAgent const captureResult = child?.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.source.callId === 'c1') expect(captureResult?.type === 'tool/result' && captureResult.data.message.isError).toBe(true) @@ -722,7 +722,7 @@ describe('in-process structured output', () => { await new Promise(resolve => setTimeout(resolve, 0)) }) - it('registrations ride the child fiber: disposing the run removes them; a provider reload mid-run cannot', async () => { + it('registrations survive provider reload and are removed before result delivery', async () => { const { ctx, parent, disposeProvider } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }), ]) @@ -731,12 +731,12 @@ describe('in-process structured output', () => { // A backend hot-reload mid-run must not unregister the capture tool out // from under the live child: the registration rides the CHILD's fiber. disposeProvider() - const result = await run.result - expect(result.structured).toEqual({ answer: 4 }) const child = run.localAgent expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeDefined() + const result = await run.result + expect(result.structured).toEqual({ answer: 4 }) + expect(ctx.agents.get(run.id)).toBeUndefined() await run.dispose() - // Child disposed ⇒ its scoped registrations are gone. expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeUndefined() }) }) From 1e437229da69e35c74b5507724cd182f73fd355e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 10:43:43 +0800 Subject: [PATCH 025/402] test(subagent): inspect children before activation completion --- .../tests/subagent-dsh-sdk.spec.ts | 4 ++-- .../tests/subagent-fork-in-process.spec.ts | 8 ++++---- .../tests/subagent-spawn-in-process.spec.ts | 17 ++++++++--------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index b401dfae25..17defd44d8 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -791,14 +791,14 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('wraps a shutdown rejection with safe facts after the runtime is reaped', async () => { const rawCleanup = 'shutdown failed at /private/path SECRET_TOKEN' const ctx = await setup() - const run = await startExternalActivation(ctx, 'dsh-sdk', request()) - await run.result const spy = vi.spyOn(DeepSeekHarness.prototype, 'close').mockImplementation(async function (this: DeepSeekHarness) { spy.mockRestore() await this.close() throw new Error(rawCleanup) }) try { + const run = await startExternalActivation(ctx, 'dsh-sdk', request()) + await run.result const error = await run.dispose().catch((cause: unknown) => cause) expect(error).toBeInstanceOf(Error) expect((error as Error).message).toBe( diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts index fec8c1bdca..f36e23922d 100644 --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts @@ -82,7 +82,7 @@ describe('dsh-subagent-fork-in-process', () => { const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('fresh child') - const child = ctx.agents.get(run.id)! + const child = run.localAgent // Only the child's own turn — no seeded parent turns. expect(child.session.snapshotEvents().filter(e => e.type === 'turn/end')).toHaveLength(1) expect(child.session.header.isSeeded).toBe(false) @@ -100,7 +100,7 @@ describe('dsh-subagent-fork-in-process', () => { const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) await run.result - const child = ctx.agents.get(run.id)! + const child = run.localAgent expect(child.session.header.isSeeded).toBe(true) expect(child.session.inheritedEventCount).toBe(parentPrefixLen) expect(child.session.snapshotEvents().slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') @@ -119,7 +119,7 @@ describe('dsh-subagent-fork-in-process', () => { expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('child answer') - const child = ctx.agents.get(run.id)! + const child = run.localAgent // The child's log STARTS with the parent's prefix (seeded), then its own turn. expect(child.session.snapshotEvents().length).toBeGreaterThan(parentPrefixLen) // The seeded prefix carried the parent's user message. @@ -151,7 +151,7 @@ describe('dsh-subagent-fork-in-process', () => { expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('child') - const child = ctx.agents.get(run.id)! + const child = run.localAgent // The child's seed has exactly the ONE completed parent turn (the open one excluded). const seedTurnEnds = child.session.snapshotEvents().filter(e => e.type === 'turn/end') // 1 from the seeded parent turn + 1 from the child's own completed turn. diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts index 328029d6b2..eb3569d34e 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts @@ -107,7 +107,7 @@ describe('dsh-subagent-spawn-in-process', () => { const { ctx, parent } = await setup([textResponse('hi')]) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) await run.result - const child = ctx.agents.get(run.id)! + const child = run.localAgent expect(child.session.header.id).not.toBe(parent.session.header.id) expect(child.session.header.parentSession).toBe(parent.session.header.id) await run.dispose() @@ -123,21 +123,20 @@ describe('dsh-subagent-spawn-in-process', () => { const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent }) await run.result - const child = ctx.agents.get(run.id)! + const child = run.localAgent // The child's first user/message is its OWN prompt, not the parent's history. const firstUser = child.session.snapshotEvents().find(e => e.type === 'user/message') expect(firstUser).toBeDefined() await run.dispose() }) - it('disposes the child to quiescence (agent removed from the registry)', async () => { + it('releases the child before delivering its result', async () => { const { ctx, parent } = await setup([textResponse('x')]) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) - await run.result expect(ctx.agents.get(run.id)).toBeDefined() - await run.dispose() - // After dispose, the child is unregistered (the AgentHandle teardown ran). + await run.result expect(ctx.agents.get(run.id)).toBeUndefined() + await run.dispose() }) it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => { @@ -145,7 +144,7 @@ describe('dsh-subagent-spawn-in-process', () => { expect(parent.options.subagentDepth).toBeUndefined() const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) await run.result - const child = ctx.agents.get(run.id)! + const child = run.localAgent expect(child.options.subagentDepth).toBe(1) await run.dispose() }) @@ -258,7 +257,7 @@ describe('dsh-subagent-spawn-in-process', () => { }) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent }) await run.result - const child = ctx.agents.get(run.id)! + const child = run.localAgent expect(child.session.header.cwd).toBe('/tmp/parent-workspace') await run.dispose() await parentHandle.dispose() @@ -432,7 +431,7 @@ describe('dsh-subagent-spawn-in-process', () => { const childRequest = adapter.requests[0]! expect((childRequest.tools ?? []).map(t => t.name)).not.toContain('forbidden_tool') // …and the attempted call executed as UNKNOWN_TOOL (visible in the log). - const child = ctx.agents.get(run.id)! + const child = run.localAgent const toolResult = child.session.snapshotEvents().find(e => e.type === 'tool/result')! expect(JSON.stringify(toolResult.data)).toContain('unknown tool') await run.dispose() From bb16d7d9890635b9c955cf47ae56b5ee23e50497 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 10:49:20 +0800 Subject: [PATCH 026/402] refactor(subagent): return listing promises directly --- docs/subsystems/subagent.i18n.yaml | 4 ++-- docs/subsystems/subagent.md | 4 ++-- docs/subsystems/subagent.zh.md | 4 ++-- packages/extensions/tool-cordis/src/api-catalog.ts | 6 +++--- packages/subagent/subagent/src/index.ts | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index f08ef88492..d25dd43a6f 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/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/subsystems/subagent.md -subagent.md: 1467982e38cab81c9d16f321ad569bc2bf29eec3 -subagent.zh.md: 50e8bfa577aac02280da4c4504f479506a6c3255 +subagent.md: dd590f29f70b980c34806a1b86d0d2e4357fca9b +subagent.zh.md: a5f6f99abda39af83f33ebe58c771fbe936b0ca5 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 1467982e38..dd590f29f7 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -507,7 +507,7 @@ async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise +listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise /** * Enumerate the root's complete session-backed subagent tree in stable @@ -524,7 +524,7 @@ async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise +listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise /** * Deliver one browser-authored message to a continuable child through the diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 50e8bfa577..a5f6f99abd 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -507,7 +507,7 @@ async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise +listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise /** * Enumerate the root's complete session-backed subagent tree in stable @@ -524,7 +524,7 @@ async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise +listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise /** * Deliver one browser-authored message to a continuable child through the diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6745e45730..c2eb75016c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2630,14 +2630,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ throws: ['{SubagentError} `UNAUTHORIZED` when a resident target belongs to a different parent or the supplied parent identity is stale.'], }, { - signature: 'async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise', + signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise', description: 'Read the parent\'s durable direct-child catalog without loading or resuming an Agent. The service owns and releases the live-preferred Session observation.', parameters: [{ name: 'parentSessionId', description: 'parent whose direct children are requested.' }, { name: 'signal', description: 'cancellation forwarded to the Session query.' }], returns: 'catalog children in parent event order.', throws: ['{@link SubagentError} when query or catalog projection is unavailable.', 'SessionQueryError when the parent cannot be read or the query is cancelled.'], }, { - signature: 'async listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise', + signature: 'listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise', description: 'Enumerate the root\'s complete session-backed subagent tree in stable pre-order from one live-preferred corpus, without loading or resuming an Agent. Ordinary sessions and one-shot children remain traversal nodes so continuable descendants below them are discovered; each returned entry adds its durable `parentId` and root-relative `depth`. Identity resolution, diagnostics, optional persistence, and cancellation use the registered child identity projection and complete Session corpus.', parameters: [{ name: 'rootSessionId', description: 'session whose complete descendant tree is listed.' }, { name: 'signal', description: 'caller-owned cancellation forwarded to persistence reads and observed around every read await.' }], returns: 'children and per-candidate diagnostics with tree position, in stable pre-order.', @@ -6522,7 +6522,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRuntime', - declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config: z;\n constructor(ctx: Context, config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n startActivation(spec: SubagentActivationSpec & {\n readonly childId: SessionId;\n }): Promise;\n startActivation(spec: SubagentActivationSpec): Promise;\n async startActivation(spec: SubagentActivationSpec): Promise;\n async waitForChildren(parent: Agent): Promise;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainDescendants(parents: readonly Agent[]): Promise;\n async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n async listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterr /* …truncated — full shape in source */', + declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config: z;\n constructor(ctx: Context, config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n startActivation(spec: SubagentActivationSpec & {\n readonly childId: SessionId;\n }): Promise;\n startActivation(spec: SubagentActivationSpec): Promise;\n async startActivation(spec: SubagentActivationSpec): Promise;\n async waitForChildren(parent: Agent): Promise;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainDescendants(parents: readonly Agent[]): Promise;\n async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n /* …truncated — full shape in source */', }, { name: 'SubagentSendMessageOptions', diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index bfb2fb661e..d6c1eae4f8 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -393,7 +393,7 @@ export class SubagentRuntime extends TypertRemoteService { * @throws {@link SubagentError} when query or catalog projection is unavailable. * @throws SessionQueryError when the parent cannot be read or the query is cancelled. */ - async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise { + listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise { return listSubagentChildren(this.ctx, parentSessionId, signal) } @@ -412,7 +412,7 @@ export class SubagentRuntime extends TypertRemoteService { * stable pre-order. * @throws {@link SubagentError} when listing dependencies are unavailable or the caller cancels. */ - async listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise { + listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise { return listSubagentDescendants(this.ctx, rootSessionId, signal) } From 554974614f8c2484621741a98055f95a033a5f17 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 11:02:51 +0800 Subject: [PATCH 027/402] refactor(subagent): restore input delivery type alias --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 ++-- docs/event-producer-consumer.md | 8 ++++---- docs/event-producer-consumer.zh.md | 8 ++++---- packages/subagent/subagent/src/activation-driver.ts | 4 ++-- packages/subagent/subagent/src/continuation-activation.ts | 8 ++++---- packages/subagent/subagent/src/control-types.ts | 3 +++ packages/subagent/subagent/src/index.ts | 3 ++- packages/subagent/subagent/src/internal.ts | 4 ++-- 11 files changed, 27 insertions(+), 23 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c926c5695b..ba6806488d 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: b8140574c88a4ffa8d28fa0554820b05e5e124ef -config-catalog.zh.md: ff56c094e59829172c14685f336d835478a530fe +config-catalog.md: f1ac1ca2680de0573d5da3880d90b27f962d5417 +config-catalog.zh.md: 3a18929e817a1308eaf3cd07599e49b6503da73b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b8140574c8..f1ac1ca268 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2697,7 +2697,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent/src/index.ts:175`](../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:176`](../packages/subagent/subagent/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ff56c094e5..3a18929e81 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2699,7 +2699,7 @@ export interface Config { } ``` -来源: [`packages/subagent/subagent/src/index.ts:191`](../packages/subagent/subagent/src/index.ts) +来源: [`packages/subagent/subagent/src/index.ts:176`](../packages/subagent/subagent/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 9b89078ee5..73c0a45630 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: 113acf1adf84376732822d4a400849fa34a2e362 -event-producer-consumer.zh.md: 739c50c4d3048b1f4e40f6b17e32514e850d6aef +event-producer-consumer.md: c5d7922b4bd61661b056b42d1f1a9d576f857eb2 +event-producer-consumer.zh.md: 021561ea3061397fec5a9a571c248c7d8f54437c diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 113acf1adf..c5d7922b4b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -62,10 +62,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:157`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:131`](../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:137`](../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:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:132`](../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:138`](../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:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 739c50c4d3..021561ea30 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -64,10 +64,10 @@ | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:157`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:131`](../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:137`](../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:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:132`](../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:138`](../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:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/subagent/subagent/src/activation-driver.ts b/packages/subagent/subagent/src/activation-driver.ts index 08061e59f5..a7ef93665b 100644 --- a/packages/subagent/subagent/src/activation-driver.ts +++ b/packages/subagent/subagent/src/activation-driver.ts @@ -10,7 +10,7 @@ import type { SessionLogOffset, UserMessage } from '@deepseek-ai/dsh-session' import { finalAssistantOutput } from './assistant-output.ts' import { SubagentError } from './error.ts' import { epochStopReason } from './lifecycle.ts' -import type { SubagentPromptRequest } from './control-types.ts' +import type { SubagentDelivery } from './control-types.ts' import type { SubagentResult, SubagentRun } from './types.ts' import type { StructuredAttachment } from './structured.ts' @@ -104,7 +104,7 @@ export class LocalActivationDriver implements ActivationLifecycle { * @param delivery - queue a new turn or steer the nearest step. * @throws when this activation has already submitted its structured result. */ - deliver(message: UserMessage, delivery: SubagentPromptRequest['delivery']): void { + deliver(message: UserMessage, delivery: SubagentDelivery): void { if (this.structured?.captured() !== undefined) { throw new SubagentError('subagent already submitted its structured result; wait for this activation to close before sending another message', 'INPUT_CLOSED') } diff --git a/packages/subagent/subagent/src/continuation-activation.ts b/packages/subagent/subagent/src/continuation-activation.ts index 81980ba190..0d4130a967 100644 --- a/packages/subagent/subagent/src/continuation-activation.ts +++ b/packages/subagent/subagent/src/continuation-activation.ts @@ -39,7 +39,7 @@ import { createSettlementMessage } from './continuation-messages.ts' import type { SubagentDescriptorData } from './descriptor.ts' import { SubagentError } from './error.ts' import { isAdjacentAgentSendMessageTool } from './internal.ts' -import type { SubagentPromptRequest } from './control-types.ts' +import type { SubagentDelivery } from './control-types.ts' import type { ActivationObserver } from './lifecycle.ts' /** Process-local slots shared through uninterrupted continuable parent links. */ @@ -260,7 +260,7 @@ export class ContinuableActivationRegistry { } /** Admit local input synchronously before any teardown can begin. */ - private deliver(activation: Activation, message: UserMessage, delivery: SubagentPromptRequest['delivery']): void { + private deliver(activation: Activation, message: UserMessage, delivery: SubagentDelivery): void { if (activation.closing !== undefined) { throw new SubagentError( 'subagent activation is being disposed; the message was not accepted', @@ -365,7 +365,7 @@ export class ContinuableActivationRegistry { * @param message - durable user message to deliver. * @param delivery - receiving inbox destination. */ - sendWaking(parent: Agent, message: UserMessage, delivery: SubagentPromptRequest['delivery']): void { + sendWaking(parent: Agent, message: UserMessage, delivery: SubagentDelivery): void { const parentActivation = this.resident.get(parent.id) if (parentActivation !== undefined && parentActivation.driver.agent === parent) { try { @@ -571,7 +571,7 @@ export class ContinuableActivationRegistry { submitAdmitted( activation: Activation, message: UserMessage, - delivery: SubagentPromptRequest['delivery'], + delivery: SubagentDelivery, parent: Agent, signal: AbortSignal, ): MessageId { diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts index 27ed730b7b..fa3a13198f 100644 --- a/packages/subagent/subagent/src/control-types.ts +++ b/packages/subagent/subagent/src/control-types.ts @@ -84,6 +84,9 @@ export type SubagentAddress = | { readonly mode: 'unknown' } ) +/** One Agent inbox destination, as the prompt request selects it. */ +export type SubagentDelivery = SubagentPromptRequest['delivery'] + /** One human message addressed to a continuable direct child. */ export interface SubagentPromptRequest { /** Identity persisted on the accepted message, minted before the call. */ diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index d6c1eae4f8..b132b44c84 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -37,6 +37,7 @@ import { rejectPrompt, validateControlRequest, } from './control.ts' import type { + SubagentDelivery, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest, @@ -325,7 +326,7 @@ export class SubagentRuntime extends TypertRemoteService { content: ContentBlock[], source: MessageSource, signal: AbortSignal, - delivery: SubagentPromptRequest['delivery'], + delivery: SubagentDelivery, ): Promise { return delivery === 'steer' ? this.requireContinuations().steerPrompt(parent, childId, content, source, signal) diff --git a/packages/subagent/subagent/src/internal.ts b/packages/subagent/subagent/src/internal.ts index c0e69509ea..4d19c55435 100644 --- a/packages/subagent/subagent/src/internal.ts +++ b/packages/subagent/subagent/src/internal.ts @@ -9,7 +9,7 @@ import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-ll import type { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import type SubagentRuntime from './index.ts' -import type { SubagentPromptRequest } from './control-types.ts' +import type { SubagentDelivery } from './control-types.ts' /** Process-stable identity carried only by the standard adjacent-Agent messaging tool. */ export const adjacentAgentSendMessageTool = Symbol.for('dsh.subagent.adjacentAgentSendMessageTool') @@ -49,7 +49,7 @@ export interface HostPromptDeliverer { content: ContentBlock[], source: MessageSource, signal: AbortSignal, - delivery: SubagentPromptRequest['delivery'], + delivery: SubagentDelivery, ): Promise } From 5ca7f533e18172f45676d489267b396ac88d7c04 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 11:09:41 +0800 Subject: [PATCH 028/402] refactor(subagent): own external runs without activation drivers --- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 8 +- docs/subsystems/subagent.zh.md | 8 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/src/activation-driver.ts | 165 ++++------------ .../subagent/src/continuation-activation.ts | 178 +++++++++++------- .../subagent/subagent/src/continuation.ts | 3 +- packages/subagent/subagent/src/types.ts | 2 +- .../subagent/tests/activation-driver.spec.ts | 44 ----- .../subagent/tests/continuation.spec.ts | 66 ++++--- .../tests/external-activation.spec.ts | 64 +++++++ 13 files changed, 266 insertions(+), 284 deletions(-) delete mode 100644 packages/subagent/subagent/tests/activation-driver.spec.ts diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index d25dd43a6f..575e568594 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/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/subsystems/subagent.md -subagent.md: dd590f29f70b980c34806a1b86d0d2e4357fca9b -subagent.zh.md: a5f6f99abda39af83f33ebe58c771fbe936b0ca5 +subagent.md: 8f1eb8f24d84f9441e6245efd3209fa9687d2771 +subagent.zh.md: 5a7fe6696e95ae0f006f32e3fa3360963523c43b diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index dd590f29f7..8f1eb8f24d 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -169,7 +169,7 @@ interface ContinuableCreateSpec { ## Messages and interruption -sendMessage authorizes the exact live sender and permits only adjacent local agents. Running children receive steering input; absent continuable children resume from persistence. External executions reject follow-ups. Local children report their content through send_message; their parent receives an automatic status-only settlement notice. External executions automatically report their final text, structured result, diagnostic, and status. caller delivery suppresses these notices. drainDescendants and drainChildren await owned work teardown for both drivers. +sendMessage authorizes the exact live sender and permits only adjacent local agents. Running children receive steering input; absent continuable children resume from persistence. External executions reject follow-ups. Local children report their content through send_message; their parent receives an automatic status-only settlement notice. External executions automatically report their final text, structured result, diagnostic, and status. caller delivery suppresses these notices. drainDescendants and drainChildren await owned work teardown for local and external activations. ```ts type-equiv /** Durable attribution for one model-authored message between adjacent Agents. */ @@ -240,7 +240,7 @@ type SubagentDescendantListEntry = SubagentListEntry & { ## Results and backend handles -SubagentResult is available to program consumers. The external driver retains the backend handle until disposal completes. A cleanup failure does not overwrite an already returned result; the parent log retains that output and records the final failure status. Backend-owned diagnostics must satisfy the safe-detail requirements below. +SubagentResult is available to program consumers. The activation registry retains the external backend handle until disposal completes. A cleanup failure does not overwrite an already returned result; the parent log retains that output and records the final failure status. Backend-owned diagnostics must satisfy the safe-detail requirements below. ```ts type-equiv /** @@ -298,7 +298,7 @@ interface SubagentStopReasonMap { ```ts type-equiv /** - * Backend execution handle owned by the external activation driver. + * Backend execution handle owned directly by the activation registry. * A result may become available before resource release; the manager always * disposes the handle and awaits cleanup. Backend startup failures clean up * partial resources before rejecting, while accepted execution failures settle @@ -325,7 +325,7 @@ interface SubagentRun { ## The provider contract: SubagentProvider -Spawn and Fork contribute detached creation inputs to the local driver. Codex, Claude Code, ACP, and DSH SDK contribute one execution handle to the external driver; they do not gain multiple turns. Both paths share admission, capacity, parent ownership, cancellation, and disposal. The [package reference](../../packages/subagent/subagent/README.md) describes composition and deployment requirements. +Spawn and Fork contribute detached creation inputs to the local driver. Codex, Claude Code, ACP, and DSH SDK contribute one execution handle directly to the activation registry; they do not gain multiple turns. External completion follows the handle’s result promise; inbox and idle admission belong only to local execution. Both paths share admission, capacity, parent ownership, cancellation, and disposal. The [package reference](../../packages/subagent/subagent/README.md) describes composition and deployment requirements. ```ts type-equiv /** diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index a5f6f99abd..5a7fe6696e 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -169,7 +169,7 @@ interface ContinuableCreateSpec { ## 消息与中断 -sendMessage 根据确切的在线发送者实例授权,仅允许相邻本地代理通信。运行中的子代理接收引导输入;不在线的可继续子代理从持久化恢复。外部执行拒绝后续消息。本地子代理通过 send_message 汇报内容;其父代理收到的自动结束通知仅包含状态。外部执行自动回传最终文本、结构化结果、诊断和状态。caller 回传会抑制这些通知。drainDescendants 与 drainChildren 对两种 driver 都等待所属任务清理完成。 +sendMessage 根据确切的在线发送者实例授权,仅允许相邻本地代理通信。运行中的子代理接收引导输入;不在线的可继续子代理从持久化恢复。外部执行拒绝后续消息。本地子代理通过 send_message 汇报内容;其父代理收到的自动结束通知仅包含状态。外部执行自动回传最终文本、结构化结果、诊断和状态。caller 回传会抑制这些通知。drainDescendants 与 drainChildren 对本地和外部 activation 都等待所属任务清理完成。 ```ts type-equiv /** Durable attribution for one model-authored message between adjacent Agents. */ @@ -240,7 +240,7 @@ type SubagentDescendantListEntry = SubagentListEntry & { ## 结果与后端句柄 -SubagentResult 供程序消费方读取。外部 driver 保留后端句柄,直到释放完成。清理失败不会覆盖已经返回的结果;父日志保留该输出并记录最终失败状态。后端提供的诊断必须满足下述安全信息要求。 +SubagentResult 供程序消费方读取。activation 注册表保留外部后端句柄,直到释放完成。清理失败不会覆盖已经返回的结果;父日志保留该输出并记录最终失败状态。后端提供的诊断必须满足下述安全信息要求。 ```ts type-equiv /** @@ -298,7 +298,7 @@ interface SubagentStopReasonMap { ```ts type-equiv /** - * Backend execution handle owned by the external activation driver. + * Backend execution handle owned directly by the activation registry. * A result may become available before resource release; the manager always * disposes the handle and awaits cleanup. Backend startup failures clean up * partial resources before rejecting, while accepted execution failures settle @@ -325,7 +325,7 @@ interface SubagentRun { ## 提供方约定:SubagentProvider -Spawn 与 Fork 向本地 driver 提供独立的创建输入。Codex、Claude Code、ACP 和 DSH SDK 向外部 driver 提供一个执行句柄,不增加多轮能力。两条路径共用准入、容量、父子所有权、取消和释放机制。[包参考](../../packages/subagent/subagent/README.zh.md)说明组合与部署要求。 +Spawn 与 Fork 向本地 driver 提供独立的创建输入。Codex、Claude Code、ACP 和 DSH SDK 直接向 activation 注册表提供一个执行句柄,不增加多轮能力。外部完成由句柄的 result promise 确定;inbox 与空闲准入仅属于本地执行。两条路径共用准入、容量、父子所有权、取消和释放机制。[包参考](../../packages/subagent/subagent/README.zh.md)说明组合与部署要求。 ```ts type-equiv /** diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 878603d783..248db309b8 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: 4685ffcf843b7159b479a294cc1084778c5c2fbb -README.zh.md: 081b18bbaecf9db1c897d1cc99feecec804760e6 +README.md: c8cfc8024cddb7ac41b37280abf22db4e604a43a +README.zh.md: 9ad0a05b240a87447ad87611f0f8befab973c972 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 4685ffcf84..c8cfc8024c 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -92,7 +92,7 @@ This section explains how the service is built and where the observable behavior | [`src/continuation-activation.ts`](src/continuation-activation.ts) | Process-local Activation graph, admission, settlement, and child-first disposal | | [`src/continuation-messages.ts`](src/continuation-messages.ts) | Adjacent-Agent messages, return guidance, and settlement notices | | [`src/internal.ts`](src/internal.ts) | Host-only Queue and Steer adapters plus standard adjacent-Agent messaging markers | -| [`src/activation-driver.ts`](src/activation-driver.ts) | Local input delivery and shared execution lifecycle adapters | +| [`src/activation-driver.ts`](src/activation-driver.ts) | Local input delivery, idle admission, and output capture | | [`src/structured.ts`](src/structured.ts) | Activation-scoped structured capture and guards | | [`src/types.ts`](src/types.ts) | Public request, result, and provider contracts | | [`src/descriptor.ts`](src/descriptor.ts) | Versioned `subagent/descriptor` session-event vocabulary | diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 081b18bbae..9ad0a05b24 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -92,7 +92,7 @@ kind: "package-reference" | [`src/continuation-activation.ts`](src/continuation-activation.ts) | 进程内 Activation 图、准入、结算与子级优先释放 | | [`src/continuation-messages.ts`](src/continuation-messages.ts) | 相邻 Agent 消息、返回指引与结算通知 | | [`src/internal.ts`](src/internal.ts) | Host 专用 Queue 与 Steer 适配器,以及标准相邻 Agent 消息标记 | -| [`src/activation-driver.ts`](src/activation-driver.ts) | 本地输入投递与共享执行生命周期适配器 | +| [`src/activation-driver.ts`](src/activation-driver.ts) | 本地输入投递、空闲准入与输出捕获 | | [`src/structured.ts`](src/structured.ts) | Activation 局部结构化捕获与保护 | | [`src/types.ts`](src/types.ts) | 公开的请求、结果与提供方约定 | | [`src/descriptor.ts`](src/descriptor.ts) | 版本化的 `subagent/descriptor` 会话事件词汇 | diff --git a/packages/subagent/subagent/src/activation-driver.ts b/packages/subagent/subagent/src/activation-driver.ts index a7ef93665b..4100fef287 100644 --- a/packages/subagent/subagent/src/activation-driver.ts +++ b/packages/subagent/subagent/src/activation-driver.ts @@ -1,6 +1,5 @@ /** - * Activation execution and settlement over a local Agent or an external run. - * The manager owns admission and residency; each driver owns its execution handle. + * Local Agent input, idle admission, and activation output capture. * * @module @deepseek-ai/dsh-subagent/activation-driver */ @@ -11,65 +10,14 @@ import { finalAssistantOutput } from './assistant-output.ts' import { SubagentError } from './error.ts' import { epochStopReason } from './lifecycle.ts' import type { SubagentDelivery } from './control-types.ts' -import type { SubagentResult, SubagentRun } from './types.ts' +import type { SubagentResult } from './types.ts' import type { StructuredAttachment } from './structured.ts' -/** Local or external execution; only the local driver accepts further input. */ -export type ActivationDriver = LocalActivationDriver | ExternalActivationDriver - -/** Execution operations used by the activation manager. */ -interface ActivationLifecycle { - /** The local Agent, absent for an external product run. */ - readonly agent: Agent | undefined - /** Whether accepted input remains unclaimed. */ - readonly hasPending: boolean - /** Monotonic revision for detecting work committed during settlement. */ - readonly version: number - /** - * Wait until execution and maintenance reach quiescence. - * @returns fulfillment after current activity settles. - */ - whenIdle(): Promise - /** - * Cancel active execution and optionally preserve unclaimed input. - * @param kind - the actor requesting cancellation. - * @param keepInbox - preserve local queued input when true. - */ - cancel(kind: 'user' | 'parent', keepInbox?: boolean): void - /** - * Flush owned durable state; failures remain visible to the manager. - * @returns fulfillment after persistence completes. - */ - flush(): Promise - /** - * Read the settled outcome of this activation's own execution. - * @returns final output and stop reason, with any committed structured value. - * @throws when an external result has not settled yet. - */ - capture(): SubagentResult - /** - * Claim idle execution and synchronously close manager admission. - * The callback starts teardown but must not await it or throw. - * @param close - synchronously close admission and start resource release. - * @returns whether idle execution was claimed and admission closed. - */ - closeWhenIdle(close: () => void): boolean - /** - * Subscribe for the driver's lifetime to input removal. - * @param wake - wake the manager's settlement observation. - */ - onInputRemoved(wake: () => void): void - /** - * Release the owned execution handle and reach quiescence. - * @returns fulfillment after resource release. - */ - dispose(): Promise -} - /** Local execution with output restricted to events produced during residency. */ -export class LocalActivationDriver implements ActivationLifecycle { +export class LocalActivationDriver { /** Execution with a local Agent inbox. */ readonly kind = 'local' + /** The live Agent retained for this residency epoch. */ readonly agent: Agent private readonly boundary: SessionLogOffset @@ -86,14 +34,20 @@ export class LocalActivationDriver implements ActivationLifecycle { this.boundary = this.agent.session.seq } + /** Whether accepted local input remains unclaimed. */ get hasPending(): boolean { return this.agent.inbox.nextTurn.length > 0 || this.agent.inbox.nextStep.length > 0 } + /** Session revision used to recheck idle admission after persistence. */ get version(): number { return this.agent.session.seq } + /** + * Wait until execution and maintenance reach quiescence. + * @returns fulfillment after current activity settles. + */ whenIdle(): Promise { return this.agent.whenIdle() } @@ -112,15 +66,28 @@ export class LocalActivationDriver implements ActivationLifecycle { else this.agent.followup(message) } + /** + * Cancel active execution and optionally preserve unclaimed input. + * @param kind - the actor requesting cancellation. + * @param keepInbox - preserve local queued input when true. + */ cancel(kind: 'user' | 'parent', keepInbox?: boolean): void { if (keepInbox === undefined) this.agent.cancel({ kind }) else this.agent.cancel({ kind }, { keepInbox }) } + /** + * Flush owned durable state; failures remain visible to the manager. + * @returns fulfillment after persistence completes. + */ async flush(): Promise { await this.agent.ctx.sessions.flush(this.agent.session) } + /** + * Read the settled outcome of this activation's own execution. + * @returns final output and stop reason, with any committed structured value. + */ capture(): SubagentResult { // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = this.agent.session.snapshotEvents(this.boundary) @@ -134,6 +101,12 @@ export class LocalActivationDriver implements ActivationLifecycle { return { output, stopReason } } + /** + * Claim idle execution and synchronously close manager admission. + * The callback starts teardown but must not await it or throw. + * @param close - synchronously close admission and start resource release. + * @returns whether idle execution was claimed and admission closed. + */ closeWhenIdle(close: () => void): boolean { try { void this.agent.runMaintenance(() => { @@ -147,84 +120,20 @@ export class LocalActivationDriver implements ActivationLifecycle { } } + /** + * Subscribe for the local handle's lifetime to input removal. + * @param wake - wake the manager's settlement observation. + */ onInputRemoved(wake: () => void): void { this.agent.ctx.on('agent/inbox/claimed', wake) this.agent.ctx.on('agent/inbox/discarded', wake) } + /** + * Release the owned execution handle and reach quiescence. + * @returns fulfillment after resource release. + */ dispose(): Promise { return this.handle.dispose() } } - -/** External execution that settles once and accepts no further input. */ -export class ExternalActivationDriver implements ActivationLifecycle { - /** Execution without continuation input. */ - readonly kind = 'external' - readonly agent = undefined - readonly hasPending = false - private terminal: SubagentResult | undefined - private failure: { error: unknown } | undefined - private readonly settled: Promise - private disposal: Promise | undefined - - /** - * Observe one published external run until its terminal result is available. - * @param run - provider-owned execution handle. - * @param controller - cancellation controller passed to the provider at start. - */ - constructor( - private readonly run: SubagentRun, - private readonly controller: AbortController, - ) { - this.settled = run.result.then( - (result) => { this.terminal = result }, - (error: unknown) => { - this.failure = { error } - this.terminal = { - output: [], - stopReason: 'error', - diagnostic: 'External subagent execution failed before returning a result.', - } - }, - ) - } - - get version(): number { - return this.terminal === undefined ? 0 : 1 - } - - whenIdle(): Promise { - return this.settled - } - - cancel(kind: 'user' | 'parent', _keepInbox?: boolean): void { - this.controller.abort({ kind }) - void this.dispose().catch(() => undefined) - } - - flush(): Promise { - return Promise.resolve() - } - - capture(): SubagentResult { - if (this.failure !== undefined) throw this.failure.error - if (this.terminal === undefined) { - throw new SubagentError(`subagent "${this.run.id}" has not settled`, 'NOT_IDLE') - } - return this.terminal - } - - closeWhenIdle(close: () => void): boolean { - if (this.terminal === undefined) return false - close() - return true - } - - onInputRemoved(_wake: () => void): void {} - - dispose(): Promise { - this.disposal ??= Promise.resolve().then(() => this.run.dispose()) - return this.disposal - } -} diff --git a/packages/subagent/subagent/src/continuation-activation.ts b/packages/subagent/subagent/src/continuation-activation.ts index 0d4130a967..f679397432 100644 --- a/packages/subagent/subagent/src/continuation-activation.ts +++ b/packages/subagent/subagent/src/continuation-activation.ts @@ -25,8 +25,7 @@ import type { } from '@deepseek-ai/dsh-session' import type { ToolRestriction, ObjectJsonSchema } from '@deepseek-ai/dsh-tools' import { assertNever } from '@deepseek-ai/dsh-util-values' -import { LocalActivationDriver, ExternalActivationDriver } from './activation-driver.ts' -import type { ActivationDriver } from './activation-driver.ts' +import { LocalActivationDriver } from './activation-driver.ts' import { attachStructuredRuntime } from './structured.ts' import type { StructuredAttachment } from './structured.ts' import type { SubagentResult, SubagentRun } from './types.ts' @@ -62,8 +61,8 @@ class ActivationPool { } /** - * One residency epoch for a local child or an external execution. Its driver - * owns the execution handle; the registry owns admission and resource release. + * One residency epoch for a local child or an external execution. + * The registry owns admission, execution handles, and resource release. */ export interface Activation { /** Shared capacity for this Activation and all its managed descendants. */ @@ -72,8 +71,12 @@ export interface Activation { readonly releaseSlot: () => void /** The durable child this Activation is an epoch of. */ readonly childId: SessionId - /** The retained live Agent handle, disposed exactly once at settlement. */ - readonly driver: ActivationDriver + /** Local Agent execution or the provider's single execution handle. */ + readonly execution: LocalActivationDriver | { + readonly kind: 'external' + readonly run: SubagentRun + readonly controller: AbortController + } readonly parent: Agent readonly delivery: 'parent' | 'caller' readonly result: PromiseWithResolvers @@ -103,6 +106,11 @@ export interface Activation { poke: PromiseWithResolvers } +/** The Agent identity used for local ancestry and parent messaging. */ +function activationAgent(activation: Activation): Agent | undefined { + return activation.execution.kind === 'local' ? activation.execution.agent : undefined +} + /** Inputs shared by fresh and resumed Activation materialization. */ export interface MaterializeInputs { childId: SessionId @@ -247,15 +255,15 @@ export class ContinuableActivationRegistry { * @throws when the target is an external execution. */ localDriver(activation: Activation): LocalActivationDriver { - const { driver } = activation - switch (driver.kind) { + const { execution } = activation + switch (execution.kind) { case 'local': - return driver + return execution case 'external': throw new SubagentError(`subagent "${activation.childId}" does not accept follow-up input`, 'NOT_CONTINUABLE') - /* v8 ignore next 2 -- Both members of the closed driver union are handled above. */ + /* v8 ignore next 2 -- Both members of the closed execution union are handled above. */ default: - return assertNever(driver) + return assertNever(execution) } } @@ -292,7 +300,7 @@ export class ContinuableActivationRegistry { */ holdOwnership(parent: Agent, childId: SessionId): () => void { const parentActivation = this.resident.get(parent.id) - if (parentActivation === undefined || parentActivation.driver.agent !== parent) return () => {} + if (parentActivation === undefined || activationAgent(parentActivation) !== parent) return () => {} if (parentActivation.closing !== undefined) { throw new SubagentError( `subagent parent "${parent.id}" is being disposed; the child was not established`, @@ -356,7 +364,13 @@ export class ContinuableActivationRegistry { // Disposal already stopped the target with a whole-Activation teardown; // a second cancel would be a redundant signal on a closing handle. if (activation.closing !== undefined) return - activation.driver.cancel(authority.kind === 'user' ? 'user' : 'parent', true) + const kind = authority.kind === 'user' ? 'user' : 'parent' + if (activation.execution.kind === 'local') { + activation.execution.cancel(kind, true) + } else { + activation.execution.controller.abort({ kind }) + void this.dispose(activation).catch((error: unknown) => { this.reportTeardownFailure(activation, error) }) + } } /** @@ -367,7 +381,7 @@ export class ContinuableActivationRegistry { */ sendWaking(parent: Agent, message: UserMessage, delivery: SubagentDelivery): void { const parentActivation = this.resident.get(parent.id) - if (parentActivation !== undefined && parentActivation.driver.agent === parent) { + if (parentActivation !== undefined && activationAgent(parentActivation) === parent) { try { this.deliver(parentActivation, message, delivery) } finally { @@ -403,7 +417,7 @@ export class ContinuableActivationRegistry { */ async waitForChildren(parent: Agent): Promise { const pending = [...this.materializations].filter(item => item.lineage.includes(parent)) - const children = [...this.resident.values()].filter(item => item.driver.agent !== parent && item.ancestry.has(parent)) + const children = [...this.resident.values()].filter(item => activationAgent(item) !== parent && item.ancestry.has(parent)) if (pending.length === 0 && children.length === 0) return false await Promise.all([ ...pending.map(item => item.settled), @@ -426,14 +440,15 @@ export class ContinuableActivationRegistry { const targets: Activation[] = [] for (const activation of this.resident.values()) { - const lineage = this.liveLineage(activation.driver.agent ?? activation.parent) - const owners = [...roots].filter(root => activation.driver.agent !== root + const child = activationAgent(activation) + const lineage = this.liveLineage(child ?? activation.parent) + const owners = [...roots].filter(root => child !== root && activation.ancestry.has(root)) if (owners.length === 0) continue targets.push(activation) for (const owner of owners) { const members = this.closingMembers(owner) - if (activation.driver.agent !== undefined) members.add(activation.driver.agent) + if (child !== undefined) members.add(child) for (const agent of lineage) members.add(agent) } } @@ -699,17 +714,18 @@ export class ContinuableActivationRegistry { structured = attachStructuredRuntime(childCtx, inputs.outputSchema, () => this.resident.get(childId)?.ownedChildren.size === 0) } } - let driver: ActivationDriver + let execution: Activation['execution'] if (inputs.external !== undefined) { const controller = new AbortController() const cancelStartup = (): void => { controller.abort(inputs.signal.reason) } inputs.signal.addEventListener('abort', cancelStartup, { once: true }) try { const run = await inputs.external(controller.signal) - driver = new ExternalActivationDriver(run, controller) + execution = { kind: 'external', run, controller } + void run.result.catch(() => undefined) childId = run.id try { this.assertChildIdAvailable(childId) } catch (error: unknown) { - await driver.dispose() + await run.dispose() throw error } } finally { @@ -735,7 +751,7 @@ export class ContinuableActivationRegistry { setup, }) - driver = new LocalActivationDriver(handle, structured) + execution = new LocalActivationDriver(handle, structured) } const observer = this.observeActivation(provider, childId, parent) @@ -743,12 +759,12 @@ export class ContinuableActivationRegistry { pool, releaseSlot, childId, - driver, + execution, parent, delivery: inputs.delivery ?? 'parent', result: Promise.withResolvers(), closing: undefined, - ancestry: new WeakSet(driver.agent === undefined ? parentLineage : [driver.agent, ...parentLineage]), + ancestry: new WeakSet(execution.kind === 'external' ? parentLineage : [execution.agent, ...parentLineage]), ownedChildren: new Set(), observer, announced: false, @@ -762,8 +778,8 @@ export class ContinuableActivationRegistry { if (this.ctx.agents.get(parent.id) !== parent) throw new SubagentError('subagent parent is no longer live', 'UNAUTHORIZED') this.acquireOwnership(parent, childId) const wakeOnInboxRemoval = (): void => { this.wake(activation) } - driver.onInputRemoved(wakeOnInboxRemoval) - observer.start(driver.agent) + if (execution.kind === 'local') execution.onInputRemoved(wakeOnInboxRemoval) + observer.start(activationAgent(activation)) } catch (error: unknown) { /* v8 ignore next -- rollback failure must not mask the admission failure * that prevented this operation from returning an accepted message id. */ @@ -780,14 +796,20 @@ export class ContinuableActivationRegistry { announce(activation: Activation): void { if (activation.announced) return activation.announced = true - this.watchSettlement(activation) + const { execution } = activation + if (execution.kind === 'local') { + this.watchLocalSettlement(activation, execution) + } else { + const finish = (): Promise => this.close(activation, () => this.finishDisposal(activation, false)) + void execution.run.result.then(finish, finish).catch((error: unknown) => { this.reportTeardownFailure(activation, error) }) + } } /** Release an Activation whose start edge was not published. */ private rollbackUnpublished(activation: Activation, error: unknown): Promise { return this.close(activation, async () => { try { - await activation.driver.dispose() + await (activation.execution.kind === 'local' ? activation.execution.dispose() : activation.execution.run.dispose()) } finally { this.resident.delete(activation.childId) activation.releaseSlot() @@ -824,14 +846,14 @@ export class ContinuableActivationRegistry { } /** Follow one Activation to natural settlement. */ - private watchSettlement(activation: Activation): void { + private watchLocalSettlement(activation: Activation, local: LocalActivationDriver): void { void (async () => { while (true) { const idleObservation = activation.poke - await activation.driver.whenIdle() + await local.whenIdle() if (activation.closing !== undefined) return const readiness = await this.locks.run(activation.childId, () => Promise.resolve( - this.settlementState(activation, idleObservation), + this.settlementState(activation, local, idleObservation), )) if (readiness === 'closed') return if (readiness === 'retry') continue @@ -840,16 +862,16 @@ export class ContinuableActivationRegistry { continue } - const finalSeq = activation.driver.version - await this.flushFinalState(activation) + const finalSeq = local.version + await this.flushFinalState(activation, local) const attempt = await this.locks.run(activation.childId, () => { - const state = this.settlementState(activation, idleObservation) + const state = this.settlementState(activation, local, idleObservation) if (state !== 'ready') return Promise.resolve(state) - if (activation.driver.version !== finalSeq) { + if (local.version !== finalSeq) { return Promise.resolve('retry') } let done!: Promise - if (!activation.driver.closeWhenIdle(() => { + if (!local.closeWhenIdle(() => { done = this.close(activation, () => this.finishDisposal(activation, false)) })) { return Promise.resolve('retry') @@ -866,23 +888,26 @@ export class ContinuableActivationRegistry { try { await attempt.done } catch (error: unknown) { - this.ctx.logger.warn( - `subagent "${activation.childId}" activation teardown failed: ${failureMessage(error)}`, - ) + this.reportTeardownFailure(activation, error) } return } })() } + private reportTeardownFailure(activation: Activation, error: unknown): void { + this.ctx.logger.warn(`subagent "${activation.childId}" activation teardown failed: ${failureMessage(error)}`) + } + /** Check pending input and owned children before closing admission. */ private settlementState( activation: Activation, + local: LocalActivationDriver, observation: PromiseWithResolvers, ): SettlementState { if (activation.closing !== undefined) return 'closed' if (activation.poke !== observation) return 'retry' - if (activation.driver.hasPending || activation.ownedChildren.size > 0) return 'wait' + if (local.hasPending || activation.ownedChildren.size > 0) return 'wait' return 'ready' } @@ -893,39 +918,51 @@ export class ContinuableActivationRegistry { const failures: SubagentError[] = [] let result: SubagentResult = { output: [], stopReason: 'error' } let resultFailure: { error: unknown } | undefined - const child = activation.driver.agent + const { execution } = activation + let externalDisposal: Promise | undefined + const child = activationAgent(activation) const includeOutput = child === undefined || !isAdjacentAgentSendMessageTool(child.ctx.get('tools')?.get('send_message', child)) - const detail = (error: unknown): string => activation.driver.agent === undefined + const detail = (error: unknown): string => child === undefined ? failureMessage(error) : errorChain(error) try { - if (stop) { - activation.driver.cancel('parent') - const idle = activation.driver.whenIdle() - const children = [...activation.ownedChildren] - .map(child => this.resident.get(child)) - .filter((child): child is Activation => child !== undefined) - const childDisposals = children.map(child => this.dispose(child)) - const childFailures = await Promise.all(childDisposals.map(async (disposal) => { - try { - await disposal - return undefined - } catch (error: unknown) { - return error - } - })) - const reasons = childFailures.filter(reason => reason !== undefined) - if (reasons.length > 0) { - failures.push(new SubagentError( - `subagent "${childId}" child teardown failed: ${reasons.map(reason => failureMessage(reason)).join('; ')}`, - 'ACTIVATION_TEARDOWN_FAILED', - )) + if (execution.kind === 'external') { + if (stop) { + execution.controller.abort({ kind: 'parent' }) + // Some backends settle their result only when disposal begins. + externalDisposal = Promise.resolve().then(() => execution.run.dispose()) + void externalDisposal.catch(() => undefined) } - await idle - await this.flushFinalState(activation) + result = await execution.run.result + } else { + if (stop) { + execution.cancel('parent') + const idle = execution.whenIdle() + const children = [...activation.ownedChildren] + .map(child => this.resident.get(child)) + .filter((child): child is Activation => child !== undefined) + const childDisposals = children.map(child => this.dispose(child)) + const childFailures = await Promise.all(childDisposals.map(async (disposal) => { + try { + await disposal + return undefined + } catch (error: unknown) { + return error + } + })) + const reasons = childFailures.filter(reason => reason !== undefined) + if (reasons.length > 0) { + failures.push(new SubagentError( + `subagent "${childId}" child teardown failed: ${reasons.map(reason => failureMessage(reason)).join('; ')}`, + 'ACTIVATION_TEARDOWN_FAILED', + )) + } + await idle + await this.flushFinalState(activation, execution) + } + result = execution.capture() } - result = activation.driver.capture() } catch (error: unknown) { resultFailure = { error } failures.push(new SubagentError( @@ -935,7 +972,8 @@ export class ContinuableActivationRegistry { )) } try { - await activation.driver.dispose() + if (execution.kind === 'local') await execution.dispose() + else await (externalDisposal ?? execution.run.dispose()) } catch (error: unknown) { failures.push(new SubagentError( `subagent "${childId}" activation handle disposal failed: ${detail(error)}`, @@ -973,7 +1011,7 @@ export class ContinuableActivationRegistry { const parent = this.ctx.agents.get(activation.parent.id) if (parent !== activation.parent) return const message = createSettlementMessage( - activation.childId, terminal, includeOutput, activation.driver.agent !== undefined, + activation.childId, terminal, includeOutput, activationAgent(activation) !== undefined, ) if (this.closingTeardownFor(parent) !== undefined) { parent.inject(message) @@ -989,9 +1027,9 @@ export class ContinuableActivationRegistry { } /** Request a best-effort final session flush before closing natural-settlement admission. */ - private async flushFinalState(activation: Activation): Promise { + private async flushFinalState(activation: Activation, local: LocalActivationDriver): Promise { try { - await activation.driver.flush() + await local.flush() } catch (error: unknown) { this.ctx.logger.warn( `subagent "${activation.childId}" best-effort final session flush failed; ` diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 90084d0a20..9e7fb5cdc7 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -278,7 +278,8 @@ export class SubagentContinuationManager { this.activations.assertAdmitting(sender) const senderActivation = this.activations.get(sender.id) if (senderActivation !== undefined - && senderActivation.driver.agent === sender + && senderActivation.execution.kind === 'local' + && senderActivation.execution.agent === sender && senderActivation.parent.id === targetId) { options.signal.throwIfAborted() return this.sendToParent(senderActivation, sender, content) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 5a2e24f7b5..6d3a05d7cb 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -282,7 +282,7 @@ export interface SubagentResult { } /** - * Backend execution handle owned by the external activation driver. + * Backend execution handle owned directly by the activation registry. * A result may become available before resource release; the manager always * disposes the handle and awaits cleanup. Backend startup failures clean up * partial resources before rejecting, while accepted execution failures settle diff --git a/packages/subagent/subagent/tests/activation-driver.spec.ts b/packages/subagent/subagent/tests/activation-driver.spec.ts deleted file mode 100644 index 06e89e9fce..0000000000 --- a/packages/subagent/subagent/tests/activation-driver.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** Driver readiness and terminal behavior before and after an external execution settles. */ - -import { describe, expect, it, vi } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session' -import { ExternalActivationDriver } from '../src/activation-driver.ts' -import type { SubagentResult } from '../src/types.ts' - -it('cannot close or expose a result before the backend settles', async () => { - const result = Promise.withResolvers() - const dispose = vi.fn(() => Promise.resolve()) - const controller = new AbortController() - const driver = new ExternalActivationDriver({ - id: SessionId('pending-external'), result: result.promise, dispose, - }, controller) - const close = vi.fn() - expect(driver.version).toBe(0) - expect(driver.closeWhenIdle(close)).toBe(false) - expect(close).not.toHaveBeenCalled() - expect(() => driver.capture()).toThrow('has not settled') - const terminal: SubagentResult = { output: [{ type: 'text', text: 'ready' }], stopReason: 'completed' } - result.resolve(terminal) - await driver.whenIdle() - expect(driver.version).toBe(1) - expect(driver.capture()).toEqual(terminal) - expect(driver.closeWhenIdle(close)).toBe(true) - expect(close).toHaveBeenCalledTimes(1) - await driver.dispose() -}) - -describe('external driver infrastructure rejection', () => { - it('waits for settlement while preserving the failure for the activation owner', async () => { - const result = Promise.withResolvers() - const failure = new Error('transport unavailable') - const dispose = vi.fn(() => Promise.resolve()) - const driver = new ExternalActivationDriver({ - id: SessionId('failed-external'), result: result.promise, dispose, - }, new AbortController()) - result.reject(failure) - await driver.whenIdle() - expect(driver.version).toBe(1) - expect(() => driver.capture()).toThrow(failure) - await driver.dispose() - }) -}) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index a1098de98a..52274f68dc 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -482,8 +482,9 @@ describe('continuable activation capacity', () => { const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const activation = continuationActivations(ctx).get(started.childId)! - const dispose = activation.driver.dispose.bind(activation.driver) - activation.driver.dispose = async () => { + const driver = continuationActivations(ctx).localDriver(activation) + const dispose = driver.dispose.bind(driver) + driver.dispose = async () => { entered.resolve(undefined) await releaseDisposal.promise await dispose() @@ -693,8 +694,9 @@ describe('SubagentRuntime.startActivation', () => { if (type === 'subagent/catalog') { const activation = continuationActivations(ctx).get(childId) if (activation === undefined) throw new Error('expected live Activation') - const dispose = activation.driver.dispose.bind(activation.driver) - vi.spyOn(activation.driver, 'dispose').mockImplementation(async () => { + const driver = continuationActivations(ctx).localDriver(activation) + const dispose = driver.dispose.bind(driver) + vi.spyOn(driver, 'dispose').mockImplementation(async () => { await dispose() throw cleanupFailure }) @@ -724,8 +726,9 @@ describe('SubagentRuntime.startActivation', () => { ctx.on('subagent/start', () => { const activation = continuationActivations(ctx).get(childId) if (activation === undefined) throw new Error('expected live Activation') - const dispose = activation.driver.dispose.bind(activation.driver) - vi.spyOn(activation.driver, 'dispose').mockImplementation(async () => { + const driver = continuationActivations(ctx).localDriver(activation) + const dispose = driver.dispose.bind(driver) + vi.spyOn(driver, 'dispose').mockImplementation(async () => { await dispose() throw cleanupFailure }) @@ -1623,8 +1626,9 @@ describe('continuable durability and teardown', () => { const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const activation = continuationActivations(ctx).get(started.childId)! - const realDispose = activation.driver.dispose.bind(activation.driver) - activation.driver.dispose = async () => { + const driver = continuationActivations(ctx).localDriver(activation) + const realDispose = driver.dispose.bind(driver) + driver.dispose = async () => { await realDispose() throw new Error('normal settlement cleanup failed') } @@ -1810,8 +1814,9 @@ describe('continuable durability and teardown', () => { const target = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const activation = continuationActivations(ctx).get(target.childId)! - const realDispose = activation.driver.dispose.bind(activation.driver) - activation.driver.dispose = async () => { + const driver = continuationActivations(ctx).localDriver(activation) + const realDispose = driver.dispose.bind(driver) + driver.dispose = async () => { await realDispose() throw new Error('selected cleanup failed') } @@ -1941,8 +1946,9 @@ describe('continuable durability and teardown', () => { const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const activation = continuationActivations(ctx).get(started.childId)! - const realDispose = activation.driver.dispose.bind(activation.driver) - activation.driver.dispose = async () => { + const driver = continuationActivations(ctx).localDriver(activation) + const realDispose = driver.dispose.bind(driver) + driver.dispose = async () => { await realDispose() throw new Error('scoped child reap failed') } @@ -2257,8 +2263,9 @@ describe('continuable review regressions', () => { ctx.on('subagent/end', ended) const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const activation = continuationActivations(ctx).get(started.childId)! - const realDispose = activation.driver.dispose.bind(activation.driver) - activation.driver.dispose = async () => { + const driver = continuationActivations(ctx).localDriver(activation) + const realDispose = driver.dispose.bind(driver) + driver.dispose = async () => { cleaning.resolve(undefined) await cleanup.promise await realDispose() @@ -2295,8 +2302,9 @@ describe('continuable review regressions', () => { expect(found).toBeDefined() return found! }) - const realDispose = activation.driver.dispose.bind(activation.driver) - activation.driver.dispose = async () => { + const driver = continuationActivations(ctx).localDriver(activation) + const realDispose = driver.dispose.bind(driver) + driver.dispose = async () => { await realDispose() throw new Error('scoped cleanup failed') } @@ -2316,7 +2324,8 @@ describe('continuable review regressions', () => { const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const activation = continuationActivations(ctx).get(started.childId)! - activation.driver.capture = () => { throw new Error('capture failed') } + const driver = continuationActivations(ctx).localDriver(activation) + driver.capture = () => { throw new Error('capture failed') } const drained = drainManager(ctx) hold.resolve(undefined) @@ -2333,7 +2342,8 @@ describe('continuable review regressions', () => { ctx.on('subagent/end', info => void ends.push(info)) const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) - continuationActivations(ctx).get(started.childId)!.driver.capture = () => { + const driver = continuationActivations(ctx).localDriver(continuationActivations(ctx).get(started.childId)!) + driver.capture = () => { throw new Error('capture failed') } @@ -2349,9 +2359,10 @@ describe('continuable review regressions', () => { const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const activation = continuationActivations(ctx).get(started.childId)! - const realDispose = activation.driver.dispose.bind(activation.driver) - activation.driver.capture = () => { throw new Error('capture failed') } - activation.driver.dispose = async () => { + const driver = continuationActivations(ctx).localDriver(activation) + const realDispose = driver.dispose.bind(driver) + driver.capture = () => { throw new Error('capture failed') } + driver.dispose = async () => { await realDispose() throw new Error('scoped cleanup failed') } @@ -2434,7 +2445,8 @@ describe('continuable review regressions', () => { const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'caller' }) const activation = continuationActivations(ctx).get(started.childId)! const child = ctx.agents.get(started.childId)! - const disposed = vi.spyOn(activation.driver, 'dispose') + const driver = continuationActivations(ctx).localDriver(activation) + const disposed = vi.spyOn(driver, 'dispose') const ended = vi.fn() ctx.on('subagent/end', ended) const lock = await holdChildLock(ctx, started.childId) @@ -3040,8 +3052,9 @@ describe('continuable settlement delivery', () => { expect(live).toBeDefined() return live! }) - const dispose = activation.driver.dispose.bind(activation.driver) - activation.driver.dispose = async () => { + const driver = continuationActivations(ctx).localDriver(activation) + const dispose = driver.dispose.bind(driver) + driver.dispose = async () => { await dispose() throw new Error('scope unwind failed') } @@ -3472,8 +3485,9 @@ describe('continuable errors', () => { // Make the grandchild's own handle disposal reject: scope teardown failure // propagates, unlike a contained `agent/disposed` listener throw. const branch = continuationActivations(ctx).get(grandchild.childId)! - const realDispose = branch.driver.dispose.bind(branch.driver) - branch.driver.dispose = async () => { + const driver = continuationActivations(ctx).localDriver(branch) + const realDispose = driver.dispose.bind(driver) + driver.dispose = async () => { await realDispose() throw new Error('grandchild reap failed') } diff --git a/packages/subagent/subagent/tests/external-activation.spec.ts b/packages/subagent/subagent/tests/external-activation.spec.ts index a0511ccf16..2708ef49c0 100644 --- a/packages/subagent/subagent/tests/external-activation.spec.ts +++ b/packages/subagent/subagent/tests/external-activation.spec.ts @@ -208,6 +208,70 @@ describe('external subagent activations', () => { expect(dispose).toHaveBeenCalledTimes(1) }) + it.each(['user', 'ancestor'] as const)('interrupts an external run with %s authority and joins cleanup', async (kind) => { + const backend = execution() + let providerSignal: AbortSignal | undefined + const aborted: SubagentResult = { output: [], stopReason: 'aborted' } + const dispose = vi.fn(() => { + backend.result.resolve(aborted) + return backend.dispose() + }) + const fixture = await setup(async ({ signal }) => { + providerSignal = signal + return { ...backend.run, dispose } + }) + const activation = await fixture.start() + const authority = kind === 'user' + ? { kind, parentSessionId: fixture.parent.id } + : { kind, agent: fixture.parent } + fixture.ctx.subagents.interrupt(activation.childId, authority) + fixture.ctx.subagents.interrupt(activation.childId, authority) + await backend.cleaning.promise + const disposal = activation.dispose() + const observed = vi.fn() + void activation.result.then(observed, observed) + try { + expect(providerSignal?.reason).toEqual({ kind: kind === 'user' ? 'user' : 'parent' }) + expect(dispose).toHaveBeenCalledTimes(1) + expect(observed).not.toHaveBeenCalled() + if (kind === 'ancestor') { + const rejected = expect(disposal).rejects.toThrow('interrupt cleanup failed') + backend.cleanup.reject(new Error('interrupt cleanup failed')) + await rejected + } else { + backend.cleanup.resolve(undefined) + await disposal + } + await expect(activation.result).resolves.toEqual(aborted) + expect(dispose).toHaveBeenCalledTimes(1) + expect(fixture.parent.inbox.nextTurn).toEqual([]) + } finally { + backend.cleanup.resolve(undefined) + await disposal.catch(() => undefined) + } + }) + + it('releases an external handle when caller cancellation prevents publication', async () => { + const backend = execution() + const error = new Error('startup cancelled') + const fixture = await setup(async () => { + fixture.controller.abort(error) + backend.result.resolve(completed) + return backend.run + }) + const rejected = expect(fixture.start()).rejects.toBe(error) + await backend.cleaning.promise + try { + expect(backend.dispose).toHaveBeenCalledTimes(1) + expect(fixture.records()).toEqual([]) + expect(fixture.parent.inbox.nextTurn).toEqual([]) + } finally { + backend.cleanup.resolve(undefined) + await rejected + } + await expect(fixture.ctx.subagents.waitForChildren(fixture.parent)).resolves.toBe(false) + }) + it.each(['parent', 'caller'] as const)('delivers the full failed result with %s delivery', async (delivery) => { const backend = execution() backend.cleanup.resolve(undefined) From 89e2c7e0ea41ccd6840064504332b2629224c1f6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 11:12:33 +0800 Subject: [PATCH 029/402] fix(subagent): report interrupted execution cleanup failures once --- packages/subagent/subagent/src/continuation-activation.ts | 3 ++- packages/subagent/subagent/tests/external-activation.spec.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/subagent/subagent/src/continuation-activation.ts b/packages/subagent/subagent/src/continuation-activation.ts index f679397432..d5caf0a0e2 100644 --- a/packages/subagent/subagent/src/continuation-activation.ts +++ b/packages/subagent/subagent/src/continuation-activation.ts @@ -369,7 +369,8 @@ export class ContinuableActivationRegistry { activation.execution.cancel(kind, true) } else { activation.execution.controller.abort({ kind }) - void this.dispose(activation).catch((error: unknown) => { this.reportTeardownFailure(activation, error) }) + // The settlement observer reports teardown failures. + void this.dispose(activation).catch(() => undefined) } } diff --git a/packages/subagent/subagent/tests/external-activation.spec.ts b/packages/subagent/subagent/tests/external-activation.spec.ts index 2708ef49c0..6e14b61e5d 100644 --- a/packages/subagent/subagent/tests/external-activation.spec.ts +++ b/packages/subagent/subagent/tests/external-activation.spec.ts @@ -221,6 +221,7 @@ describe('external subagent activations', () => { return { ...backend.run, dispose } }) const activation = await fixture.start() + const warning = vi.spyOn(fixture.ctx.logger, 'warn') const authority = kind === 'user' ? { kind, parentSessionId: fixture.parent.id } : { kind, agent: fixture.parent } @@ -238,6 +239,7 @@ describe('external subagent activations', () => { const rejected = expect(disposal).rejects.toThrow('interrupt cleanup failed') backend.cleanup.reject(new Error('interrupt cleanup failed')) await rejected + await vi.waitFor(() => { expect(warning).toHaveBeenCalledTimes(1) }) } else { backend.cleanup.resolve(undefined) await disposal From 390fbe8852508fd9a5db0e4d351680edae621275 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 11:58:14 +0800 Subject: [PATCH 030/402] refactor(subagent): merge orchestration and activation ownership --- ...-explicit-agent-runtime-identity.i18n.yaml | 4 +- ...6-08-31-explicit-agent-runtime-identity.md | 4 +- ...8-31-explicit-agent-runtime-identity.zh.md | 4 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 10 +- docs/subsystems/subagent.zh.md | 10 +- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 9 +- packages/subagent/subagent/README.zh.md | 9 +- .../subagent/src/activation-driver.ts | 139 --- packages/subagent/subagent/src/activation.ts | 164 +++ .../subagent/subagent/src/continuation.ts | 625 ---------- packages/subagent/subagent/src/index.ts | 44 +- ...{continuation-activation.ts => manager.ts} | 1076 +++++++++++------ packages/subagent/subagent/src/types.ts | 2 +- .../subagent/tests/continuation-internals.ts | 30 - .../subagent/tests/continuation.spec.ts | 192 +-- .../tests/external-activation.spec.ts | 4 +- .../subagent/tests/manager-internals.ts | 29 + 20 files changed, 1076 insertions(+), 1293 deletions(-) delete mode 100644 packages/subagent/subagent/src/activation-driver.ts create mode 100644 packages/subagent/subagent/src/activation.ts delete mode 100644 packages/subagent/subagent/src/continuation.ts rename packages/subagent/subagent/src/{continuation-activation.ts => manager.ts} (52%) delete mode 100644 packages/subagent/subagent/tests/continuation-internals.ts create mode 100644 packages/subagent/subagent/tests/manager-internals.ts diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml index 4fac92ed8c..6581609b76 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.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-08-31-explicit-agent-runtime-identity.md -2026-08-31-explicit-agent-runtime-identity.md: f52b8ec116c312a27306fe73dc0bd5b99fcd9039 -2026-08-31-explicit-agent-runtime-identity.zh.md: 6b6fd2f2ea1f1645069264f09fd53c3f71e1d02f +2026-08-31-explicit-agent-runtime-identity.md: d3fc7b23498e1ecdd5a54ae01d34063afe53edd2 +2026-08-31-explicit-agent-runtime-identity.zh.md: d968a29b8ec7b661337a54679a22d5dfdb6e3982 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md index f52b8ec116..d3fc7b2349 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -10,7 +10,7 @@ An Agent's Cordis Context owns registrations and their cleanup. Agent identity i The reverse association also required compensating mechanisms after type erasure. Host Remote forwarding inspected a routed subject for its Context, creation inferred runtime parentage from the caller Context, and adapters maintained reverse identity scans. These mechanisms duplicated identity already present in typed requests and obscured which caller owned an Agent at runtime. -Without an explicit owner, `SubagentContinuationManager` creates and resumes children through its private plugin Context, so Context-based inference classifies every continuable child as a runtime root even though the manager holds its exact parent. Root-only consumers could then attach scheduling tools, grant direct-human goal authority, or route user questions as if the child were top-level. +Without an explicit owner, `SubagentManager` creates and resumes children through its private plugin Context, so Context-based inference classifies every continuable child as a runtime root even though the manager holds its exact parent. Root-only consumers could then attach scheduling tools, grant direct-human goal authority, or route user questions as if the child were top-level. ## Decision @@ -18,7 +18,7 @@ Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations. -`SubagentContinuationManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. +`SubagentManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. The [Agent registration-scope decision](2026-07-08-agent-scope-contexts.md), its [runtime design](2026-07-12-agent-scope-runtime-design.md), and the [initiator-scope decision](2026-07-15-agent-initiator-scope.md) retain their independent registration, lifecycle, and private-chain rationale. This decision supersedes only the reverse Context association and implicit runtime-owner derivation described there. diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md index 6b6fd2f2ea..d968a29b8e 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -10,7 +10,7 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 类型信息被擦除后,这项反向关联还需要补偿机制。Host Remote 转发会从已路由主体检查其 Context,创建流程会从调用方 Context 推断运行时父级,适配器则维护反向身份扫描。这些机制重复类型化请求中已有的身份,也掩盖了哪个调用方在运行时拥有 Agent。 -若没有显式所属方,`SubagentContinuationManager` 会通过私有插件 Context 创建和恢复子级,因此基于 Context 的推断会把每个可续跑子级归类为 runtime root,尽管管理器持有其确切父级。仅限根级的消费方随后可能附加调度工具、授予直接人类输入对应的 Goal 权限,或像处理顶层 Agent 一样路由用户问题。 +若没有显式所属方,`SubagentManager` 会通过私有插件 Context 创建和恢复子级,因此基于 Context 的推断会把每个可续跑子级归类为 runtime root,尽管管理器持有其确切父级。仅限根级的消费方随后可能附加调度工具、授予直接人类输入对应的 Goal 权限,或像处理顶层 Agent 一样路由用户问题。 ## 决策 @@ -18,7 +18,7 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 -`SubagentContinuationManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 +`SubagentManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 [Agent 注册作用域决策](2026-07-08-agent-scope-contexts.zh.md)、其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md)和[发起方作用域决策](2026-07-15-agent-initiator-scope.zh.md)继续拥有各自独立的注册、生命周期及私有调用链理由。本决策只取代其中描述的反向 Context 关联和隐式运行时所属方推导。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 575e568594..a1273b2293 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/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/subsystems/subagent.md -subagent.md: 8f1eb8f24d84f9441e6245efd3209fa9687d2771 -subagent.zh.md: 5a7fe6696e95ae0f006f32e3fa3360963523c43b +subagent.md: 75164dbde60fd53d1ab8d1783c6c40d01b4f8868 +subagent.zh.md: 2ee5b2cb12178b33c5980cfbb727e3fbc907d640 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 8f1eb8f24d..75164dbde6 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -6,7 +6,7 @@ The unified activation API manages local conversations and external executions. ## Capabilities and activation requests -Every backend uses startActivation. Capability flags validate request options; prepareContinuable selects the local driver, while start selects one external execution. The model-facing tool uses parent delivery. Workflow and code mode consumers can await caller results without injecting another parent message. Historical children with unavailable descriptors remain discoverable as `mode: 'unknown'` without granting continuation capabilities; the [package README](../../packages/subagent/subagent/README.md) defines catalog persistence. +Every backend uses startActivation. Capability flags validate request options; prepareContinuable selects local Agent creation, while start selects one external execution. The model-facing tool uses parent delivery. Workflow and code mode consumers can await caller results without injecting another parent message. Historical children with unavailable descriptors remain discoverable as `mode: 'unknown'` without granting continuation capabilities; the [package README](../../packages/subagent/subagent/README.md) defines catalog persistence. ```ts type-equiv /** @@ -325,7 +325,7 @@ interface SubagentRun { ## The provider contract: SubagentProvider -Spawn and Fork contribute detached creation inputs to the local driver. Codex, Claude Code, ACP, and DSH SDK contribute one execution handle directly to the activation registry; they do not gain multiple turns. External completion follows the handle’s result promise; inbox and idle admission belong only to local execution. Both paths share admission, capacity, parent ownership, cancellation, and disposal. The [package reference](../../packages/subagent/subagent/README.md) describes composition and deployment requirements. +`SubagentManager` owns startup, message admission, and execution lifetimes. Spawn and Fork contribute detached creation inputs for local Agents. Codex, Claude Code, ACP, and DSH SDK contribute one execution handle; they do not gain multiple turns. Each activation is an execution record that directly retains its AgentHandle or SubagentRun. External completion follows the handle’s result promise; inbox and idle admission belong only to local execution. Both paths share admission, capacity, parent ownership, cancellation, and disposal. The [package reference](../../packages/subagent/subagent/README.md) describes composition and deployment requirements. ```ts type-equiv /** @@ -463,7 +463,7 @@ async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], * signal is issued before this returns, but the target may keep running * until it observes the signal. Unclaimed pending inbox work, the Activation, * and published descendants are preserved; claimed work is not requeued. - * Once the interrupted driver is idle, a waking send resumes the parked FIFO + * Once the interrupted Agent is idle, a waking send resumes the parked FIFO * queue. External backends stop their single execution. An absent target * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. @@ -481,7 +481,7 @@ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): vo * 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 driver. + * @returns once every retained descendant activation released its execution handle. * @throws an aggregate error after all branches settle when any failed. */ async drainDescendants(parents: readonly Agent[]): Promise @@ -492,7 +492,7 @@ async drainDescendants(parents: readonly Agent[]): Promise * Absent targets and a manager-less composition are accepted no-ops. * @param parent - exact live direct parent authorizing the selected release. * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected activation released its driver. + * @returns once every selected activation released its execution handle. * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a * different parent or the supplied parent identity is stale. */ diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 5a7fe6696e..2ee5b2cb12 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -6,7 +6,7 @@ ## 能力与 activation 请求 -所有后端都使用 startActivation。能力标记校验请求选项;prepareContinuable 选择本地 driver,start 选择一次外部执行。面向模型的工具采用 parent 回传。Workflow 与 code mode 消费者可以等待 caller 结果,不额外向父代理注入消息。历史子会话的 descriptor 不可用时,以 `mode: 'unknown'` 保持可发现性,但不授予继续执行能力;[包 README](../../packages/subagent/subagent/README.zh.md) 定义目录持久化语义。 +所有后端都使用 startActivation。能力标记校验请求选项;prepareContinuable 选择本地 Agent 创建,start 选择一次外部执行。面向模型的工具采用 parent 回传。Workflow 与 code mode 消费者可以等待 caller 结果,不额外向父代理注入消息。历史子会话的 descriptor 不可用时,以 `mode: 'unknown'` 保持可发现性,但不授予继续执行能力;[包 README](../../packages/subagent/subagent/README.zh.md) 定义目录持久化语义。 ```ts type-equiv /** @@ -325,7 +325,7 @@ interface SubagentRun { ## 提供方约定:SubagentProvider -Spawn 与 Fork 向本地 driver 提供独立的创建输入。Codex、Claude Code、ACP 和 DSH SDK 直接向 activation 注册表提供一个执行句柄,不增加多轮能力。外部完成由句柄的 result promise 确定;inbox 与空闲准入仅属于本地执行。两条路径共用准入、容量、父子所有权、取消和释放机制。[包参考](../../packages/subagent/subagent/README.zh.md)说明组合与部署要求。 +`SubagentManager` 统一管理启动、消息准入与执行生命周期。Spawn 与 Fork 提供用于本地 Agent 的独立创建输入。Codex、Claude Code、ACP 和 DSH SDK 提供一个执行句柄,不增加多轮能力。每条 activation 都是直接持有 AgentHandle 或 SubagentRun 的执行期记录。外部完成由句柄的 result promise 确定;inbox 与空闲准入仅属于本地执行。两条路径共用准入、容量、父子所有权、取消和释放机制。[包参考](../../packages/subagent/subagent/README.zh.md)说明组合与部署要求。 ```ts type-equiv /** @@ -463,7 +463,7 @@ async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], * signal is issued before this returns, but the target may keep running * until it observes the signal. Unclaimed pending inbox work, the Activation, * and published descendants are preserved; claimed work is not requeued. - * Once the interrupted driver is idle, a waking send resumes the parked FIFO + * Once the interrupted Agent is idle, a waking send resumes the parked FIFO * queue. External backends stop their single execution. An absent target * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. @@ -481,7 +481,7 @@ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): vo * 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 driver. + * @returns once every retained descendant activation released its execution handle. * @throws an aggregate error after all branches settle when any failed. */ async drainDescendants(parents: readonly Agent[]): Promise @@ -492,7 +492,7 @@ async drainDescendants(parents: readonly Agent[]): Promise * Absent targets and a manager-less composition are accepted no-ops. * @param parent - exact live direct parent authorizing the selected release. * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected activation released its driver. + * @returns once every selected activation released its execution handle. * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a * different parent or the supplied parent identity is stale. */ diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c2eb75016c..59a9125fdd 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2611,7 +2611,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void', - description: 'Interrupt one live child\'s current execution under a human parent address or an exact live ancestor Agent. Fire-and-return: the cancel signal is issued before this returns, but the target may keep running until it observes the signal. Unclaimed pending inbox work, the Activation, and published descendants are preserved; claimed work is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. External backends stop their single execution. An absent target is an accepted no-op, as is a manager-less composition, which cannot own a live Activation.', + description: 'Interrupt one live child\'s current execution under a human parent address or an exact live ancestor Agent. Fire-and-return: the cancel signal is issued before this returns, but the target may keep running until it observes the signal. Unclaimed pending inbox work, the Activation, and published descendants are preserved; claimed work is not requeued. Once the interrupted Agent is idle, a waking send resumes the parked FIFO queue. External backends stop their single execution. An absent target is an accepted no-op, as is a manager-less composition, which cannot own a live Activation.', parameters: [{ name: 'targetSessionId', description: 'the durable child session id to interrupt.' }, { name: 'authority', description: 'the human parent address or exact live ancestor Agent.' }], throws: ['{SubagentError} `UNAUTHORIZED` when the authority does not own the live target.'], }, @@ -2619,14 +2619,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async drainDescendants(parents: readonly Agent[]): Promise', description: 'Close subagent 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.', parameters: [{ name: 'parents', description: 'exact host-owned parent Agents entering teardown.' }], - returns: 'once every retained descendant activation released its driver.', + returns: 'once every retained descendant activation released its execution handle.', throws: ['an aggregate error after all branches settle when any failed.'], }, { signature: 'async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise', description: 'Release selected resident direct children of one exact live parent. Other children of the same parent remain admitted and resident. Absent targets and a manager-less composition are accepted no-ops.', parameters: [{ name: 'parent', description: 'exact live direct parent authorizing the selected release.' }, { name: 'childIds', description: 'durable direct-child ids to release when resident.' }], - returns: 'once every selected activation released its driver.', + returns: 'once every selected activation released its execution handle.', throws: ['{SubagentError} `UNAUTHORIZED` when a resident target belongs to a different parent or the supplied parent identity is stale.'], }, { diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 248db309b8..c62db36c46 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: c8cfc8024cddb7ac41b37280abf22db4e604a43a -README.zh.md: 9ad0a05b240a87447ad87611f0f8befab973c972 +README.md: ffbba7a308306fa3690c5dcca0c93cb2070d6300 +README.zh.md: a7d5c668b8507f0baf4367ecc47564771581c0c9 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index c8cfc8024c..ffbba7a308 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -79,7 +79,7 @@ This section explains how the service is built and where the observable behavior ### Design concept - **One service, many providers.** The service is a named-provider registry; each backend registers under a unique name and a request picks one by name. -- **One managed lifetime.** Local Agents and external executions share activation ownership, capacity, result delivery, and disposal. +- **One managed lifetime.** `SubagentManager` owns startup, message admission, capacity, parent relationships, and disposal. Each activation retains a local AgentHandle or external SubagentRun directly; local inbox and idle state belong only to the local variant. - **Fulfillment is publication.** `startActivation()` returns only after the child is accepted and its handle can cancel that exact execution. - **Trusted same-process values.** Requests, descriptors, and results are borrowed immutable; serialization and hostile-input validation belong at process and wire boundaries. @@ -88,11 +88,10 @@ This section explains how the service is built and where the observable behavior | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Service entry: provider registry, start and continuation API, lifecycle events | -| [`src/continuation.ts`](src/continuation.ts) | Continuable orchestration: identity reservation, provider preparation, cold resume, authorization, routing | -| [`src/continuation-activation.ts`](src/continuation-activation.ts) | Process-local Activation graph, admission, settlement, and child-first disposal | +| [`src/manager.ts`](src/manager.ts) | Unified startup, message admission, cold resume, parent ownership, settlement, and disposal | +| [`src/activation.ts`](src/activation.ts) | Execution records, capacity slots, child locks, and local output capture | | [`src/continuation-messages.ts`](src/continuation-messages.ts) | Adjacent-Agent messages, return guidance, and settlement notices | | [`src/internal.ts`](src/internal.ts) | Host-only Queue and Steer adapters plus standard adjacent-Agent messaging markers | -| [`src/activation-driver.ts`](src/activation-driver.ts) | Local input delivery, idle admission, and output capture | | [`src/structured.ts`](src/structured.ts) | Activation-scoped structured capture and guards | | [`src/types.ts`](src/types.ts) | Public request, result, and provider contracts | | [`src/descriptor.ts`](src/descriptor.ts) | Versioned `subagent/descriptor` session-event vocabulary | @@ -186,7 +185,7 @@ These limits define when the seam is a poor fit or needs special operational car - **External children execute once** — ACP, DSH SDK, Codex, and Claude Code have no local child Session and accept no follow-up input. Their parent-owned records remain discoverable after disposal. - **Adjacent model messaging only** — `sendMessage()` requires an exact live sender; every sender may target a direct continuable child, while only a sender with a resident continuable Activation may target its direct parent. Browser prompts use a separate human Queue-or-Steer control path. - **A direct parent must remain live for child-to-parent delivery** — the service has no durable parent mailbox; a missing parent rejects the message instead of accepting work it cannot wake. -- **Wake gap during cancellation convergence** — a follow-up accepted after an interrupt signal but before the driver becomes idle stays queued until another waking send. +- **Wake gap during cancellation convergence** — a follow-up accepted after an interrupt signal but before the Agent becomes idle stays queued until another waking send. - **Pending injected context retains an Activation** — settlement conservatively treats every Inbox occurrence as unfinished. Context parked after the Agent becomes idle keeps the child and its live ancestors resident until a waking delivery claims it, a queue mutation removes it, or manager teardown discards it. - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store needs a durable mailbox and cross-process lease protocol. - **No replay of accepted-but-unlogged messages** — a crash can lose an accepted prompt that never reached the child's session log; the lost message is not replayed automatically. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 9ad0a05b24..a7d5c668b8 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -79,7 +79,7 @@ kind: "package-reference" ### 设计理念 - **一个服务,多个提供方。** 服务是具名提供方注册表;每个后端以唯一名称注册,请求按名称选择一个。 -- **统一受管理生命周期。** 本地 Agent 和外部执行共享 activation 所有权、容量、结果投递与资源释放。 +- **统一受管理生命周期。** `SubagentManager` 统一管理启动、消息准入、容量、父子关系与释放。每条 activation 直接持有本地 AgentHandle 或外部 SubagentRun;本地 inbox 与空闲状态仅属于本地分支。 - **兑现即发布。** `startActivation()` 仅在子级已接受且句柄可取消该次执行后返回。 - **同进程值可信。** 请求、描述符与结果按不可变约定借用;序列化与不可信输入校验属于进程与协议边界。 @@ -88,11 +88,10 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 服务入口:提供方注册表、启动与继续 API、生命周期事件 | -| [`src/continuation.ts`](src/continuation.ts) | 可继续子级编排:身份预留、提供方准备、冷恢复、授权与路由 | -| [`src/continuation-activation.ts`](src/continuation-activation.ts) | 进程内 Activation 图、准入、结算与子级优先释放 | +| [`src/manager.ts`](src/manager.ts) | 统一管理启动、消息准入、冷恢复、父子所有权、结算与释放 | +| [`src/activation.ts`](src/activation.ts) | 执行期记录、容量槽、子级锁与本地输出捕获 | | [`src/continuation-messages.ts`](src/continuation-messages.ts) | 相邻 Agent 消息、返回指引与结算通知 | | [`src/internal.ts`](src/internal.ts) | Host 专用 Queue 与 Steer 适配器,以及标准相邻 Agent 消息标记 | -| [`src/activation-driver.ts`](src/activation-driver.ts) | 本地输入投递、空闲准入与输出捕获 | | [`src/structured.ts`](src/structured.ts) | Activation 局部结构化捕获与保护 | | [`src/types.ts`](src/types.ts) | 公开的请求、结果与提供方约定 | | [`src/descriptor.ts`](src/descriptor.ts) | 版本化的 `subagent/descriptor` 会话事件词汇 | @@ -186,7 +185,7 @@ You are a delegated subagent: your permission scope was fixed when you were star - **外部子级执行一次**——ACP、DSH SDK、Codex 与 Claude Code 没有本地子 Session,也不接受后续输入。释放后仍可发现父级拥有的执行记录。 - **仅允许相邻模型消息**——`sendMessage()` 要求确切在线 sender;每个 sender 都可以指定直接可继续 child,只有具备驻留可继续 Activation 的 sender 可以指定自己的直接 parent。浏览器提示使用独立的人类 Queue 或 Steer 控制路径。 - **child 到 parent 的投递要求直接 parent 保持在线**——服务没有持久 parent mailbox;parent 缺失时会拒绝消息,而非接受无法唤醒的工作。 -- **取消收敛期间存在唤醒缺口**——中断信号发出后、driver 进入 idle 前被接受的后续消息会保持排队,直到另一条唤醒发送到达。 +- **取消收敛期间存在唤醒缺口**——中断信号发出后、Agent 进入 idle 前被接受的后续消息会保持排队,直到另一条唤醒发送到达。 - **待处理的注入上下文会保留 Activation**——settlement 会保守地把每个 Inbox occurrence 都视为未完成。Agent 进入 idle 后停放的上下文会让 child 及其在线祖先继续驻留,直到唤醒投递将其 claim、queue 变更将其移除,或 manager teardown 将其丢弃。 - **驻留仅限进程内**——Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问需要持久化邮箱与跨进程租约协议。 - **不回放已接受但未记录的消息**——崩溃可能丢失从未写入子会话日志、已被接受的提示词;丢失的消息不会自动回放。 diff --git a/packages/subagent/subagent/src/activation-driver.ts b/packages/subagent/subagent/src/activation-driver.ts deleted file mode 100644 index 4100fef287..0000000000 --- a/packages/subagent/subagent/src/activation-driver.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Local Agent input, idle admission, and activation output capture. - * - * @module @deepseek-ai/dsh-subagent/activation-driver - */ - -import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' -import type { SessionLogOffset, UserMessage } from '@deepseek-ai/dsh-session' -import { finalAssistantOutput } from './assistant-output.ts' -import { SubagentError } from './error.ts' -import { epochStopReason } from './lifecycle.ts' -import type { SubagentDelivery } from './control-types.ts' -import type { SubagentResult } from './types.ts' -import type { StructuredAttachment } from './structured.ts' - -/** Local execution with output restricted to events produced during residency. */ -export class LocalActivationDriver { - /** Execution with a local Agent inbox. */ - readonly kind = 'local' - /** The live Agent retained for this residency epoch. */ - readonly agent: Agent - private readonly boundary: SessionLogOffset - - /** - * Own one published local handle before submitting activation input. - * @param handle - the local Agent and its resource release operation. - * @param structured - committed-value reader when structured output is required. - */ - constructor( - private readonly handle: AgentHandle, - private readonly structured?: StructuredAttachment, - ) { - this.agent = handle.agent - this.boundary = this.agent.session.seq - } - - /** Whether accepted local input remains unclaimed. */ - get hasPending(): boolean { - return this.agent.inbox.nextTurn.length > 0 || this.agent.inbox.nextStep.length > 0 - } - - /** Session revision used to recheck idle admission after persistence. */ - get version(): number { - return this.agent.session.seq - } - - /** - * Wait until execution and maintenance reach quiescence. - * @returns fulfillment after current activity settles. - */ - whenIdle(): Promise { - return this.agent.whenIdle() - } - - /** - * Submit accepted input to the local Agent. - * @param message - identified input accepted by the manager. - * @param delivery - queue a new turn or steer the nearest step. - * @throws when this activation has already submitted its structured result. - */ - deliver(message: UserMessage, delivery: SubagentDelivery): void { - if (this.structured?.captured() !== undefined) { - throw new SubagentError('subagent already submitted its structured result; wait for this activation to close before sending another message', 'INPUT_CLOSED') - } - if (delivery === 'steer') this.agent.steer(message) - else this.agent.followup(message) - } - - /** - * Cancel active execution and optionally preserve unclaimed input. - * @param kind - the actor requesting cancellation. - * @param keepInbox - preserve local queued input when true. - */ - cancel(kind: 'user' | 'parent', keepInbox?: boolean): void { - if (keepInbox === undefined) this.agent.cancel({ kind }) - else this.agent.cancel({ kind }, { keepInbox }) - } - - /** - * Flush owned durable state; failures remain visible to the manager. - * @returns fulfillment after persistence completes. - */ - async flush(): Promise { - await this.agent.ctx.sessions.flush(this.agent.session) - } - - /** - * Read the settled outcome of this activation's own execution. - * @returns final output and stop reason, with any committed structured value. - */ - capture(): SubagentResult { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. - const events = this.agent.session.snapshotEvents(this.boundary) - const output = finalAssistantOutput(events) ?? [] - const stopReason = epochStopReason(events) - if (this.structured !== undefined) { - const captured = this.structured.captured() - if (captured !== undefined) return { output, stopReason, structured: captured.value } - if (stopReason === 'completed') return { output, stopReason: 'error' } - } - return { output, stopReason } - } - - /** - * Claim idle execution and synchronously close manager admission. - * The callback starts teardown but must not await it or throw. - * @param close - synchronously close admission and start resource release. - * @returns whether idle execution was claimed and admission closed. - */ - closeWhenIdle(close: () => void): boolean { - try { - void this.agent.runMaintenance(() => { - close() - return Promise.resolve() - }) - return true - } catch (_error: unknown) { - // Another activity already owns the Agent's idle phase. - return false - } - } - - /** - * Subscribe for the local handle's lifetime to input removal. - * @param wake - wake the manager's settlement observation. - */ - onInputRemoved(wake: () => void): void { - this.agent.ctx.on('agent/inbox/claimed', wake) - this.agent.ctx.on('agent/inbox/discarded', wake) - } - - /** - * Release the owned execution handle and reach quiescence. - * @returns fulfillment after resource release. - */ - dispose(): Promise { - return this.handle.dispose() - } -} diff --git a/packages/subagent/subagent/src/activation.ts b/packages/subagent/subagent/src/activation.ts new file mode 100644 index 0000000000..fe3828c0f8 --- /dev/null +++ b/packages/subagent/subagent/src/activation.ts @@ -0,0 +1,164 @@ +/** Execution-period records, capacity slots, child serialization, and local output capture. + * @module @deepseek-ai/dsh-subagent/activation + */ + +import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' +import type { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { finalAssistantOutput } from './assistant-output.ts' +import { epochStopReason } from './lifecycle.ts' +import type { ActivationObserver } from './lifecycle.ts' +import { SubagentError } from './error.ts' +import type { StructuredAttachment } from './structured.ts' +import type { SubagentResult, SubagentRun } from './types.ts' + +/** Process-local slots shared through uninterrupted continuable parent links. */ +export class ActivationPool { + private readonly slots = new Set() + + /** + * Reserve before reconstruction; release also tolerates unpublished rollback. + * @param capacity - the maximum simultaneous activations in this pool. + * @returns an idempotent slot release. + */ + reserve(capacity: number): () => void { + if (this.slots.size >= capacity) { + throw new SubagentError( + `subagent limit reached (active child limit: ${capacity}); wait for an existing child to finish ` + + 'or complete this work with the current agents', + 'ACTIVATION_LIMIT_REACHED', + ) + } + const slot = Symbol() + this.slots.add(slot) + return () => { this.slots.delete(slot) } + } +} + +/** + * One residency epoch for a local child or an external execution. + * The manager owns admission, execution handles, and resource release. + */ +interface ActivationBase { + /** Shared capacity for this Activation and all its managed descendants. */ + readonly pool: ActivationPool + /** Return this epoch's slot after its handle has finished disposal. */ + readonly releaseSlot: () => void + /** The durable child this Activation is an epoch of. */ + readonly childId: SessionId + readonly parent: Agent + readonly delivery: 'parent' | 'caller' + readonly result: PromiseWithResolvers + /** Published synchronously before teardown; subsequent callers share this transaction. */ + closing: Promise | undefined + /** + * 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 + * 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 + /** + * Whether any delivery to this child was ever accepted. A materialization + * rolled back before its first acceptance is a child the caller was told does + * not exist, so its teardown owes the parent no settlement account. + */ + announced: boolean +} + +/** Resources retained only for the selected execution kind. */ +export type ActivationResource = + | { + readonly kind: 'local' + readonly handle: AgentHandle + readonly outputStart: SessionLogOffset + readonly structured: StructuredAttachment | undefined + /** Renewed when local settlement must recheck input or owned children. */ + poke: PromiseWithResolvers + } + | { + readonly kind: 'external' + readonly run: SubagentRun + readonly controller: AbortController + } + +/** One managed execution period with its provider-specific resources. */ +export type Activation = ActivationBase & ActivationResource + +/** Local residency with input admission and Session-backed result capture. */ +export type LocalActivation = Extract + +/** + * Read the live Agent retained by local execution. + * @param activation - the managed local or external execution. + * @returns the local Agent, absent for an external run. + */ +export function activationAgent(activation: Activation): Agent | undefined { + return activation.kind === 'local' ? activation.handle.agent : undefined +} + +/** + * Require local residency before accepting further input. + * @param activation - execution targeted by message delivery. + * @returns the local activation. + * @throws when the external backend cannot accept another message. + */ +export function requireLocalActivation(activation: Activation): LocalActivation { + switch (activation.kind) { + case 'local': return activation + case 'external': + throw new SubagentError(`subagent "${activation.childId}" does not accept follow-up input`, 'NOT_CONTINUABLE') + /* v8 ignore next 2 -- Both execution kinds are handled above. */ + default: return assertNever(activation) + } +} + +/** + * Capture output produced during the local residency period. + * @param activation - the settled local execution and any structured result. + * @returns final output, stop reason, and committed structured value. + */ +export function captureLocalResult(activation: LocalActivation): SubagentResult { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + const events = activation.handle.agent.session.snapshotEvents(activation.outputStart) + const output = finalAssistantOutput(events) ?? [] + const stopReason = epochStopReason(events) + const structured = activation.structured + if (structured !== undefined) { + const captured = structured.captured() + if (captured !== undefined) return { output, stopReason, structured: captured.value } + if (stopReason === 'completed') return { output, stopReason: 'error' } + } + return { output, stopReason } +} + +/** Serialize each durable child's delivery, release, and disposal. */ +export 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 + } +} diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts deleted file mode 100644 index 9e7fb5cdc7..0000000000 --- a/packages/subagent/subagent/src/continuation.ts +++ /dev/null @@ -1,625 +0,0 @@ -/** - * Subagent orchestration behind `ctx.subagents`: stable child ids, - * descriptor persistence, provider preparation, cold resume, authorization, - * and message routing. {@link ContinuableActivationRegistry} owns the mutable - * process-local Activation graph and its settlement and disposal lifecycle. - * - * A continuable child has one durable Session and at most one process-local - * Activation. The Agent inbox is the only turn queue, so this manager owns - * durable orchestration while the Agent loop owns all turn ordering and - * execution. External drivers retain one backend execution per activation. - * - * @module @deepseek-ai/dsh-subagent - */ - -import { randomUUID } from 'node:crypto' -import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { brandString } from '@deepseek-ai/dsh-brand' -import { ReasoningEffortId, contentHasImage, createUserMessage } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' -import { SessionLogOffset } from '@deepseek-ai/dsh-session' -import type { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' -import { - childSessionMeta, - captureDelegatedPolicyOverrides, - resolveChildAgentOptions, - resolveChildDepth, -} from './child-agent.ts' -import { - ContinuableActivationRegistry, -} from './continuation-activation.ts' -import type { Activation } from './continuation-activation.ts' -import { - createAgentMessage, - withContinuableReturnGuidance, -} from './continuation-messages.ts' -import { assertSubagentMaxDepth } from './depth.ts' -import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' -import { establishCatalogChild } from './catalog.ts' -import { SubagentError } from './error.ts' -import { isAdjacentAgentSendMessageTool } from './internal.ts' -import type { ActivationObserver } from './lifecycle.ts' -import type { - ContinuableCreateRequest, - ContinuableCreateSpec, - SubagentInterruptAuthority, - SubagentActivationSpec, - SubagentActivation, - SubagentStartRequest, - SubagentRun, - SubagentSendMessageOptions, -} from './types.ts' - -/** Inputs shared by model steering and human prompt delivery. */ -type ChildDeliveryOptions = - | { - readonly delivery: 'steer' - /** - * A provided host source is preserved on the user message; omission attributes - * an adjacent-Agent message to the parent. - */ - readonly source?: MessageSource - readonly signal: AbortSignal - } - | { readonly delivery: 'queue'; readonly source: MessageSource; readonly signal: AbortSignal } - -/** Package-private hooks supplied by the owning service. */ -interface ContinuationHost { - startExternal(name: string, request: SubagentStartRequest): Promise - /** Resolve one provider's detached continuable-creation contribution. */ - prepareContinuable(name: string, request: ContinuableCreateRequest): Promise - /** Build the lifecycle observer for one Activation residency epoch. */ - observeActivation(provider: string, childId: SessionId, parent: Agent): ActivationObserver -} - -/** - * Activation orchestration behind `ctx.subagents`: local conversations and - * external executions share admission, ownership, interruption, and release. - */ -export class SubagentContinuationManager { - private readonly activations: ContinuableActivationRegistry - - constructor( - private readonly ctx: Context, - private readonly host: ContinuationHost, - maxActiveSubagents: () => number, - ) { - this.activations = new ContinuableActivationRegistry( - ctx, - (provider, childId, parent) => host.observeActivation(provider, childId, parent), - maxActiveSubagents, - ) - } - - /** - * Start one continuable background child and resolve at initial inbox acceptance. - * Every earlier failure disposes any created handle and rolls back Activation - * and parent ownership without returning either id. - * @param spec - provider, delegation request, and caller cancellation. - * @returns the durable child id and accepted initial prompt message id. - */ - async startContinuable(spec: SubagentActivationSpec): Promise { - const request = spec.request - const parent = request.parent - this.activations.assertAdmitting(parent) - const persistence = spec.delivery === 'caller' ? this.ctx.get('sessionPersistence') : this.requirePersistence() - assertSubagentMaxDepth(request.maxDepth) - const childId = spec.childId ?? brandString(randomUUID()) - this.activations.assertChildIdAvailable(childId) - 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 agentOptions = resolveChildAgentOptions(parent, request.agentOptions, childDepth) - const agentProvider = agentOptions.provider - const agentModel = agentOptions.model - const agentReasoningEffort = agentOptions.reasoningEffort - const descriptor = snapshotSubagentDescriptor({ - mode: 'continuable', - provider: spec.provider, - label: spec.label, - ...agentProvider !== undefined ? { agentProvider } : {}, - ...agentModel !== undefined ? { agentModel } : {}, - ...agentReasoningEffort !== undefined ? { agentReasoningEffort } : {}, - ...request.persona !== undefined ? { persona: request.persona } : {}, - ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, - }) - // Capture before the first await: a later parent switch belongs to the - // parent's future, not to this child. - const delegatedPolicies = captureDelegatedPolicyOverrides(parent) - - // An idle continuation-managed parent must not settle while a caller is - // still creating its child. A turn-scoped delegation does not need this, - // but the service is also callable outside a turn. - const releaseHold = this.activations.holdOwnership(parent, childId) - try { - const prepared = await this.host.prepareContinuable(spec.provider, { - sessionId: childId, - parent, - signal: spec.signal, - }) - spec.signal.throwIfAborted() - this.activations.assertAdmitting(parent) - - const inheritedEventCount = SessionLogOffset(prepared.seed?.length ?? 0) - const seed = prepared.seed - let established!: Activation - const messageId = await this.activations.locks.run(childId, async () => { - spec.signal.throwIfAborted() - this.activations.assertAdmitting(parent) - this.activations.assertChildIdAvailable(childId) - if (spec.childId !== undefined) { - const persisted = await persistence?.stat(childId, { signal: spec.signal }) - spec.signal.throwIfAborted() - this.activations.assertAdmitting(parent) - this.activations.assertChildIdAvailable(childId) - if (persisted !== undefined) { - throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD') - } - } - const activation = await this.activations.materialize({ - childId, - provider: spec.provider, - parent, - create: { - seed, - meta: childSessionMeta(parent, childDepth, prepared.seed !== undefined), - inheritedEventCount, - delegatedPolicies, - descriptor, - }, - agentOptions, - composition: { persona: request.persona, toolFilter: request.toolFilter }, - signal: spec.signal, - delivery: spec.delivery, - outputSchema: spec.request.outputSchema, - }) - established = activation - const child = this.activations.localDriver(activation).agent - const childHeader = child.session.header - return await this.submitMaterialized( - activation, - spec.delivery !== 'caller' && isAdjacentAgentSendMessageTool(this.ctx.get('tools')?.get('send_message', child)) - ? withContinuableReturnGuidance(parent.id, request.prompt) - : request.prompt, - { source: { kind: 'user' }, signal: spec.signal, delivery: 'queue' }, - parent, - () => { establishCatalogChild(parent.session, childHeader, descriptor) }, - ) - }) - return { ...this.receipt(established), childId, messageId } - } catch (error: unknown) { - releaseHold() - throw error - } - } - - /** - * Start an external backend under activation ownership. - * @param spec - task, provider, cancellation, and delivery policy. - * @returns the accepted activation and its result/disposal capabilities. - */ - async startExternal(spec: SubagentActivationSpec): Promise { - const parent = spec.request.parent - this.activations.assertAdmitting(parent) - const pendingId = brandString(randomUUID()) - const releaseHold = this.activations.holdOwnership(parent, pendingId) - let activation: Activation | undefined - try { - activation = await this.activations.materialize({ - childId: pendingId, - provider: spec.provider, - parent, - agentOptions: {}, - composition: {}, - signal: spec.signal, - delivery: spec.delivery, - external: signal => this.host.startExternal(spec.provider, { - ...spec.request, label: spec.label, signal, - }), - }) - this.activations.assertAdmitting(parent) - this.activations.authorizeLineage(parent, activation.childId, activation.parent.id) - spec.signal.throwIfAborted() - const childId = activation.childId - parent.session.append('subagent/catalog', { - version: 0, childId, childCreatedAt: Date.now(), mode: 'one-shot', label: spec.label, external: true, - }) - this.activations.announce(activation) - return this.receipt(activation) - } catch (error: unknown) { - if (activation !== undefined) { - try { - await this.activations.dispose(activation) - } catch (cleanupError: unknown) { - this.ctx.logger.warn(`subagent "${activation.childId}" admission rollback failed: ${(cleanupError as SubagentError).message}`) - } - } - throw error - } finally { - releaseHold() - } - } - - - /** Return capabilities bound to an exact activation instead of a future same-id instance. */ - private receipt(activation: Activation): SubagentActivation { - return { - childId: activation.childId, - result: activation.result.promise, - dispose: () => this.activations.dispose(activation), - } - } - - /** - * Deliver one model-authored message to a direct continuable child or to the - * sender's direct parent. A missing direct child cold-resumes through the - * ordinary continuation lifecycle. - * @param sender - exact live Agent authorizing and originating the message. - * @param targetId - durable direct-parent or direct-child session id. - * @param content - model-authored content to deliver. - * @param options - caller cancellation before acceptance. - * @returns the accepted message's inbox id. - */ - async sendMessage( - sender: Agent, - targetId: SessionId, - content: ContentBlock[], - options: SubagentSendMessageOptions, - ): Promise { - if (this.ctx.agents.get(sender.id) !== sender) { - throw new SubagentError( - 'message delivery requires the exact live sender agent', - 'UNAUTHORIZED', - ) - } - this.activations.assertAdmitting(sender) - const senderActivation = this.activations.get(sender.id) - if (senderActivation !== undefined - && senderActivation.execution.kind === 'local' - && senderActivation.execution.agent === sender - && senderActivation.parent.id === targetId) { - options.signal.throwIfAborted() - return this.sendToParent(senderActivation, sender, content) - } - if (sender.session.header.parentSession === targetId) { - throw new SubagentError( - `agent "${sender.id}" is not a resident continuable child and cannot send to parent "${targetId}"`, - 'UNAUTHORIZED', - ) - } - return this.deliverToChild(sender, targetId, content, { - signal: options.signal, - delivery: 'steer', - }) - } - - /** - * Queue one human-authored prompt as a distinct direct-child turn. - * @param parent - exact live direct parent authorizing delivery. - * @param childId - durable direct-child session id. - * @param content - model-visible prompt blocks. - * @param source - durable attribution for the human prompt. - * @param signal - caller cancellation before inbox acceptance. - * @returns the accepted durable message id. - */ - async queuePrompt( - parent: Agent, - childId: SessionId, - content: ContentBlock[], - source: MessageSource, - signal: AbortSignal, - ): Promise { - return this.deliverToChild(parent, childId, content, { source, signal, delivery: 'queue' }) - } - - /** - * Steer one host-authored prompt to a direct continuable child. - * @param parent - exact live direct parent authorizing delivery. - * @param childId - durable direct-child session id. - * @param content - model-visible prompt blocks. - * @param source - durable attribution for the host prompt. - * @param signal - caller cancellation before inbox acceptance. - * @returns the accepted durable message id. - */ - async steerPrompt( - parent: Agent, - childId: SessionId, - content: ContentBlock[], - source: MessageSource, - signal: AbortSignal, - ): Promise { - return this.deliverToChild(parent, childId, content, { source, signal, delivery: 'steer' }) - } - - /** Route one parent-originated delivery through residency and cold resume. */ - private async deliverToChild( - parent: Agent, - childId: SessionId, - content: ContentBlock[], - options: ChildDeliveryOptions, - ): Promise { - this.activations.assertAdmitting(parent) - const releaseHold = this.activations.holdOwnership(parent, childId) - try { - return await this.deliverFollowup(parent, childId, content, options) - } catch (error: unknown) { - releaseHold() - throw error - } - } - - /** The delivery loop behind {@link deliverToChild}, run under the parent hold. */ - private async deliverFollowup( - parent: Agent, - childId: SessionId, - content: ContentBlock[], - options: ChildDeliveryOptions, - ): Promise { - while (true) { - const live = await this.activations.locks.run(childId, async () => { - const activation = this.activations.get(childId) - if (activation === undefined) return this.coldResume(parent, childId, content, options) - this.activations.localDriver(activation) - const disposal = activation.closing - /* v8 ignore next 3 -- the send-versus-dispose cutoff needs a delivery to - * observe the transaction inside the same critical section that opened it. */ - if (disposal !== undefined) { - return disposal.then(() => undefined, () => undefined) - } - if (contentHasImage(content)) { - await this.assertImageCapable(this.activations.localDriver(activation).agent, options.signal) - if (activation.closing !== undefined) { - await Promise.allSettled([activation.closing]) - return undefined - } - } - const messageId = this.submitAdmitted(activation, content, options, parent) - this.activations.announce(activation) - return messageId - }) - /* v8 ignore start -- only a delivery that lost the disposal cutoff retries. */ - if (live !== undefined) return live - this.activations.assertAdmitting(parent) - options.signal.throwIfAborted() - /* v8 ignore stop */ - } - } - - /** - * Interrupt one live continuable child's current turn. Admission is - * synchronous and the cancellation effect is asynchronous. An absent or - * already-closing target is an accepted no-op after authority checks. - * @param targetSessionId - the durable child session id to interrupt. - * @param authority - the human parent address or exact live ancestor Agent. - */ - interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void { - this.activations.interrupt(targetSessionId, authority) - } - - /** Deliver one resident continuable child's message to its live direct parent. */ - private sendToParent( - activation: Activation, - sender: Agent, - content: ContentBlock[], - ): MessageId { - /* v8 ignore next 6 -- only synchronous re-entrant teardown can open this - * transaction between exact-agent authorization and this no-await span. */ - if (activation.closing !== undefined) { - throw new SubagentError( - `subagent "${sender.id}" activation is being disposed; the message was not delivered`, - 'ACTIVATION_CLOSING', - ) - } - const parent = this.ctx.agents.get(activation.parent.id) - if (parent !== activation.parent) { - throw new SubagentError( - 'direct parent is not live; the message was not delivered', - 'PARENT_UNAVAILABLE', - ) - } - const message = createAgentMessage(sender, content) - this.sendAgentMessage(parent, message) - return message.id - } - - /** Send one Agent message while translating only the target's own rejection. */ - private sendAgentMessage( - parent: Agent, - message: ReturnType, - ): void { - try { - this.activations.sendWaking(parent, message, 'steer') - } catch (error: unknown) { - throw new SubagentError( - 'direct parent is not live; the message was not delivered', - 'PARENT_UNAVAILABLE', - { cause: error }, - ) - } - } - - /** - * Wait for the parent's currently owned child work to release its resources. - * @param parent - exact live parent whose child work is observed. - * @returns whether any child work was observed. - */ - waitForChildren(parent: Agent): Promise { - return this.activations.waitForChildren(parent) - } - - /** Close manager-wide admission and release every live Activation. */ - async drain(): Promise { - await this.activations.drain() - } - - /** - * Stop the managed descendants of exact live host-owned parents. - * @param parents - exact live roots whose managed descendants must stop. - */ - async drainDescendants(parents: readonly Agent[]): Promise { - await this.activations.drainDescendants(parents) - } - - /** - * Release selected resident direct children of one exact live parent. - * @param parent - exact live direct parent authorizing the selected release. - * @param childIds - durable direct-child ids to release when resident. - */ - async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise { - await this.activations.drainChildren(parent, childIds) - } - - /** - * Cold-resume a persisted child and submit the waiting turn. The descriptor - * supplies every reconstruction input; no subagent provider is dispatched. - */ - private async coldResume( - parent: Agent, - childId: SessionId, - content: ContentBlock[], - options: ChildDeliveryOptions, - ): Promise { - const query = this.requireSessionQuery() - let observation: SessionObservation - try { - observation = await query.observeSession(childId, { - signal: options.signal, - }) - } catch (error: unknown) { - options.signal.throwIfAborted() - throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) - } - using source = observation - this.activations.assertAdmitting(parent) - this.activations.authorizeLineage(parent, childId, source.header.parentSession) - const descriptor = foldSubagentDescriptor( - source.events.slice(source.inheritedEventCount), - ) - if (descriptor === undefined || descriptor.mode !== 'continuable') { - throw new SubagentError( - `subagent "${childId}" has no supported continuation state and cannot be resumed; choose a different target`, - 'NOT_RESUMABLE', - ) - } - let activation: Activation - try { - activation = await this.activations.materialize({ - childId, - provider: descriptor.provider, - parent, - agentOptions: { - ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, - ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, - ...descriptor.agentReasoningEffort !== undefined - ? { reasoningEffort: ReasoningEffortId(descriptor.agentReasoningEffort) } - : {}, - }, - composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, - signal: options.signal, - }) - } catch (error: unknown) { - options.signal.throwIfAborted() - if (error instanceof SubagentError) throw error - throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) - } - return await this.submitMaterialized(activation, content, options, parent) - } - - /** Admit a materialized child, commit its creation fact, and release it on failure. */ - private async submitMaterialized( - activation: Activation, - content: ContentBlock[], - options: ChildDeliveryOptions, - parent: Agent, - commit?: () => void, - ): Promise { - try { - if (contentHasImage(content)) { - await this.assertImageCapable(this.activations.localDriver(activation).agent, options.signal) - if (activation.closing !== undefined) { - throw new SubagentError(`subagent "${activation.childId}" is closing`, 'ACTIVATION_CLOSING') - } - } - const messageId = this.submitAdmitted(activation, content, options, parent) - commit?.() - this.activations.announce(activation) - return messageId - } catch (error: unknown) { - try { - await this.activations.dispose(activation) - } catch (cleanupError: unknown) { - this.ctx.logger.warn( - `subagent continuation: disposal after admission or catalog append failure also failed: ${String(cleanupError)}`, - ) - } - throw error - } - } - - /** Build and submit one message across the final synchronous admission cutoff. */ - private submitAdmitted( - activation: Activation, - content: ContentBlock[], - options: ChildDeliveryOptions, - parent: Agent, - ): MessageId { - const message = options.source === undefined - ? createAgentMessage(parent, content) - : createUserMessage({ content, source: options.source }) - return this.activations.submitAdmitted( - activation, - message, - options.delivery, - parent, - options.signal, - ) - } - - /** Refuse image content for a child whose fixed model accepts text only. */ - private async assertImageCapable( - agent: Agent, - signal: AbortSignal, - ): Promise { - const { provider, model } = agent.options - if (provider === undefined || model === undefined) return - const llm = this.ctx.get('llm') - /* v8 ignore next -- without an LLM registry, delivery defers to projection. */ - if (llm === undefined) return - const info = await llm.resolveModelInfo(provider, model, signal) - if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) { - throw new SubagentError( - `Model "${model}" does not support image input.`, - 'MODEL_DOES_NOT_SUPPORT_IMAGES', - ) - } - } - - /** Resolve the persistence service continuable children require, or fail loud. */ - private requirePersistence(): SessionPersistence { - const persistence = this.ctx.get('sessionPersistence') - if (persistence === undefined) { - throw new SubagentError( - 'continuable subagents require session persistence (load a dsh-session-persistence backend)', - 'PERSISTENCE_UNAVAILABLE', - ) - } - return persistence - } - - /** Resolve the Session query service used for cold child observations. */ - private requireSessionQuery(): SessionQueryEngine { - const query = this.ctx.get('sessionQuery') - if (query === undefined) { - throw new SubagentError( - 'continuable subagents require session query (load @deepseek-ai/dsh-session-query)', - 'CONTINUATION_UNAVAILABLE', - ) - } - return query - } -} - -export default SubagentContinuationManager diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index b132b44c84..b2d4dfcf9a 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -60,7 +60,7 @@ import { SubagentError } from './error.ts' import { assertSubagentMaxDepth } from './depth.ts' import { createActivationObserver, createLifecycleEmitter } from './lifecycle.ts' import type { ActivationObserver, LifecycleEmitter } from './lifecycle.ts' -import SubagentContinuationManager from './continuation.ts' +import SubagentManager from './manager.ts' import { listChildren as listSubagentChildren, listDescendants as listSubagentDescendants } from './list-children.ts' import type { SubagentDescendantListEntry } from './list-children.ts' import type { SubagentCatalogEntry } from './projection-types.ts' @@ -188,7 +188,7 @@ export class SubagentRuntime extends TypertRemoteService { }) private settingsSource: () => Config private providers = new Map() - private continuations: SubagentContinuationManager | undefined + private manager: SubagentManager | undefined /** * The contained lifecycle-edge publisher. Built here because scoped dispatch * keys its carrier by this exact service instance, whose own context filter @@ -209,7 +209,7 @@ export class SubagentRuntime extends TypertRemoteService { }) this.emitLifecycle = createLifecycleEmitter(this.ctx, parent => scopeTarget(this, parent)) ctx.inject(['agents'], (childCtx: Context) => { - const manager = new SubagentContinuationManager(childCtx, { + const manager = new SubagentManager(childCtx, { startExternal: (name, request) => { const provider = this.expectProvider(name) as SubagentProvider & Required> return provider.start(request) @@ -217,11 +217,11 @@ export class SubagentRuntime extends TypertRemoteService { prepareContinuable: (name, request) => this.prepareContinuable(name, request), observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent), }, () => (this.settingsSource() as Required).maxActiveSubagents) - this.continuations = manager + this.manager = 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()') + if (this.manager === manager) this.manager = undefined + }, 'subagents.managerBinding()') }) ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.register(subagentCatalogProjectionDefinition) @@ -270,10 +270,10 @@ export class SubagentRuntime extends TypertRemoteService { throw new SubagentError('subagent creation requires the exact live parent agent', 'UNAUTHORIZED') } if (spec.request.outputSchema !== undefined) assertObjectJsonSchema(spec.request.outputSchema) - const manager = this.requireContinuations() + const manager = this.requireManager() return provider.prepareContinuable === undefined ? manager.startExternal(spec) - : manager.startContinuable(spec) + : manager.startLocal(spec) } /** @@ -282,7 +282,7 @@ export class SubagentRuntime extends TypertRemoteService { * @returns whether any work was observed; hosts recheck parent idle after true. */ async waitForChildren(parent: Agent): Promise { - return this.continuations?.waitForChildren(parent) ?? false + return this.manager?.waitForChildren(parent) ?? false } /** @@ -305,7 +305,7 @@ export class SubagentRuntime extends TypertRemoteService { content: ContentBlock[], options: SubagentSendMessageOptions, ): Promise { - return this.requireContinuations().sendMessage(sender, targetId, content, options) + return this.requireManager().sendMessage(sender, targetId, content, options) } /** @@ -329,8 +329,8 @@ export class SubagentRuntime extends TypertRemoteService { delivery: SubagentDelivery, ): Promise { return delivery === 'steer' - ? this.requireContinuations().steerPrompt(parent, childId, content, source, signal) - : this.requireContinuations().queuePrompt(parent, childId, content, source, signal) + ? this.requireManager().steerPrompt(parent, childId, content, source, signal) + : this.requireManager().queuePrompt(parent, childId, content, source, signal) } /** @@ -339,7 +339,7 @@ export class SubagentRuntime extends TypertRemoteService { * signal is issued before this returns, but the target may keep running * until it observes the signal. Unclaimed pending inbox work, the Activation, * and published descendants are preserved; claimed work is not requeued. - * Once the interrupted driver is idle, a waking send resumes the parked FIFO + * Once the interrupted Agent is idle, a waking send resumes the parked FIFO * queue. External backends stop their single execution. An absent target * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. @@ -349,7 +349,7 @@ export class SubagentRuntime extends TypertRemoteService { * live target. */ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void { - this.continuations?.interrupt(targetSessionId, authority) + this.manager?.interrupt(targetSessionId, authority) } /** @@ -359,11 +359,11 @@ export class SubagentRuntime extends TypertRemoteService { * 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 driver. + * @returns once every retained descendant activation released its execution handle. * @throws an aggregate error after all branches settle when any failed. */ async drainDescendants(parents: readonly Agent[]): Promise { - const manager = this.continuations + const manager = this.manager // An absent activation manager cannot own materialized children. if (manager === undefined) return await manager.drainDescendants(parents) @@ -375,12 +375,12 @@ export class SubagentRuntime extends TypertRemoteService { * Absent targets and a manager-less composition are accepted no-ops. * @param parent - exact live direct parent authorizing the selected release. * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected activation released its driver. + * @returns once every selected activation released its execution handle. * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a * different parent or the supplied parent identity is stale. */ async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise { - const manager = this.continuations + const manager = this.manager if (manager === undefined) return await manager.drainChildren(parent, childIds) } @@ -590,15 +590,15 @@ export class SubagentRuntime extends TypertRemoteService { return provider } - /** Resolve the optional continuable-subagent manager or fail loud. */ - private requireContinuations(): SubagentContinuationManager { - if (this.continuations === undefined) { + /** Resolve the subagent manager or fail loud. */ + private requireManager(): SubagentManager { + if (this.manager === undefined) { throw new SubagentError( 'continuable subagents require the agents service', 'CONTINUATION_UNAVAILABLE', ) } - return this.continuations + return this.manager } /** diff --git a/packages/subagent/subagent/src/continuation-activation.ts b/packages/subagent/subagent/src/manager.ts similarity index 52% rename from packages/subagent/subagent/src/continuation-activation.ts rename to packages/subagent/subagent/src/manager.ts index d5caf0a0e2..dce3c622a6 100644 --- a/packages/subagent/subagent/src/continuation-activation.ts +++ b/packages/subagent/subagent/src/manager.ts @@ -1,144 +1,103 @@ /** - * Process-local activation ownership for every subagent: admission, - * parent-child residency, serialized delivery, settlement, and disposal. + * Owns subagent startup, message admission, cold resume, and execution lifetimes. + * One child lock orders input acceptance and closure; one activation map owns + * capacity, parent relationships, and resource release across all providers. * - * The continuation manager owns durable request orchestration and delegates - * every mutable residency decision to this registry, so delivery and teardown - * share one child lock and one Activation map. - * - * @module @deepseek-ai/dsh-subagent/continuation-activation + * @module @deepseek-ai/dsh-subagent/manager */ +import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' -import type { - Agent, - AgentOptions, - CreateAgentOptions, -} from '@deepseek-ai/dsh-agent' -import { errorChain } from '@deepseek-ai/dsh-llm' -import type { MessageId } from '@deepseek-ai/dsh-llm' -import type { - SessionEvent, - SessionId, - SessionLogOffset as SessionLogOffsetType, - UserMessage, -} from '@deepseek-ai/dsh-session' +import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' +import { brandString } from '@deepseek-ai/dsh-brand' +import { ReasoningEffortId, contentHasImage, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionId, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' import type { ToolRestriction, ObjectJsonSchema } from '@deepseek-ai/dsh-tools' -import { assertNever } from '@deepseek-ai/dsh-util-values' -import { LocalActivationDriver } from './activation-driver.ts' -import { attachStructuredRuntime } from './structured.ts' -import type { StructuredAttachment } from './structured.ts' -import type { SubagentResult, SubagentRun } from './types.ts' import { - appendDelegatedPolicyOverrides, - applyChildComposition, + appendDelegatedPolicyOverrides, applyChildComposition, captureDelegatedPolicyOverrides, + childSessionMeta, resolveChildAgentOptions, resolveChildDepth, } from './child-agent.ts' import type { DelegatedPolicyOverrides } from './child-agent.ts' -import { createSettlementMessage } from './continuation-messages.ts' +import { createAgentMessage, withContinuableReturnGuidance, createSettlementMessage } from './continuation-messages.ts' +import { assertSubagentMaxDepth } from './depth.ts' +import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' import type { SubagentDescriptorData } from './descriptor.ts' +import { establishCatalogChild } from './catalog.ts' import { SubagentError } from './error.ts' import { isAdjacentAgentSendMessageTool } from './internal.ts' -import type { SubagentDelivery } from './control-types.ts' import type { ActivationObserver } from './lifecycle.ts' +import type { + ContinuableCreateRequest, ContinuableCreateSpec, SubagentInterruptAuthority, + SubagentActivationSpec, SubagentActivation, SubagentStartRequest, SubagentRun, + SubagentSendMessageOptions, SubagentResult, +} from './types.ts' +import { attachStructuredRuntime } from './structured.ts' +import type { StructuredAttachment } from './structured.ts' +import type { SubagentDelivery } from './control-types.ts' +import { ActivationPool, ChildLock, activationAgent, captureLocalResult, requireLocalActivation } from './activation.ts' +import type { Activation, ActivationResource, LocalActivation } from './activation.ts' -/** Process-local slots shared through uninterrupted continuable parent links. */ -class ActivationPool { - private readonly slots = new Set() - - /** Reserve before reconstruction; the returned release also tolerates unpublished rollback. */ - reserve(capacity: number): () => void { - if (this.slots.size >= capacity) { - throw new SubagentError( - `subagent limit reached (active child limit: ${capacity}); wait for an existing child to finish ` - + 'or complete this work with the current agents', - 'ACTIVATION_LIMIT_REACHED', - ) - } - const slot = Symbol() - this.slots.add(slot) - return () => { this.slots.delete(slot) } +/** Inputs shared by model steering and human prompt delivery. */ +type ChildDeliveryOptions = + | { + readonly delivery: 'steer' + /** + * A provided host source is preserved on the user message; omission attributes + * an adjacent-Agent message to the parent. + */ + readonly source?: MessageSource + readonly signal: AbortSignal } -} + | { readonly delivery: 'queue'; readonly source: MessageSource; readonly signal: AbortSignal } -/** - * One residency epoch for a local child or an external execution. - * The registry owns admission, execution handles, and resource release. - */ -export interface Activation { - /** Shared capacity for this Activation and all its managed descendants. */ - readonly pool: ActivationPool - /** Return this epoch's slot after its handle has finished disposal. */ - readonly releaseSlot: () => void - /** The durable child this Activation is an epoch of. */ - readonly childId: SessionId - /** Local Agent execution or the provider's single execution handle. */ - readonly execution: LocalActivationDriver | { - readonly kind: 'external' - readonly run: SubagentRun - readonly controller: AbortController - } - readonly parent: Agent - readonly delivery: 'parent' | 'caller' - readonly result: PromiseWithResolvers - /** Published synchronously before teardown; subsequent callers share this transaction. */ - closing: Promise | undefined - /** - * 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 - * 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 - /** - * Whether any delivery to this child was ever accepted. A materialization - * rolled back before its first acceptance is a child the caller was told does - * not exist, so its teardown owes the parent no settlement account. - */ - announced: boolean - /** Renewed whenever a settlement watcher must re-check residency state. */ - poke: PromiseWithResolvers -} - -/** The Agent identity used for local ancestry and parent messaging. */ -function activationAgent(activation: Activation): Agent | undefined { - return activation.execution.kind === 'local' ? activation.execution.agent : undefined +/** Package-private hooks supplied by the owning service. */ +interface SubagentHost { + startExternal(name: string, request: SubagentStartRequest): Promise + /** Resolve one provider's detached continuable-creation contribution. */ + prepareContinuable(name: string, request: ContinuableCreateRequest): Promise + /** Build the lifecycle observer for one Activation residency epoch. */ + observeActivation(provider: string, childId: SessionId, parent: Agent): ActivationObserver } /** Inputs shared by fresh and resumed Activation materialization. */ -export interface MaterializeInputs { +interface MaterializeBase { childId: SessionId provider: string parent: Agent - /** - * Creation inputs; absent for a cold resume, which loads the persisted - * session — including the delegation policy events a fresh creation seeded, - * so a resume never re-captures the parent's policy. - */ - create?: { - seed: readonly SessionEvent[] | undefined - meta: NonNullable - /** Exact parent-log prefix length inside {@link seed}. */ - inheritedEventCount: SessionLogOffsetType - /** Policy captured at delegation: the parent's sandbox override plus the approval pin. */ - delegatedPolicies: DelegatedPolicyOverrides - /** Child-owned composition record appended after the inherited marker. */ - descriptor: SubagentDescriptorData - } - agentOptions: AgentOptions - composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } signal: AbortSignal delivery?: 'parent' | 'caller' | undefined - outputSchema?: ObjectJsonSchema | undefined - external?: (signal: AbortSignal) => Promise } +/** Creation inputs for a local residency or one external backend execution. */ +type MaterializeInputs = MaterializeBase & ( + | { + kind: 'local' + /** + * Creation inputs; absent for a cold resume, which loads the persisted + * session — including the delegation policy events a fresh creation seeded, + * so a resume never re-captures the parent's policy. + */ + create?: { + seed: readonly SessionEvent[] | undefined + meta: NonNullable + /** Exact parent-log prefix length inside {@link seed}. */ + inheritedEventCount: SessionLogOffset + /** Policy captured at delegation: the parent's sandbox override plus the approval pin. */ + delegatedPolicies: DelegatedPolicyOverrides + /** Child-owned composition record appended after the inherited marker. */ + descriptor: SubagentDescriptorData + } + agentOptions: AgentOptions + composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } + outputSchema?: ObjectJsonSchema | undefined + } + | { kind: 'external'; start: (signal: AbortSignal) => Promise } +) + /** * One admitted materialization and the exact live ancestry observed at its * synchronous admission point. Retaining identities lets a scoped teardown @@ -163,32 +122,8 @@ function failureMessage(error: unknown): string { return error instanceof Error ? error.message : 'unknown teardown failure' } -/** Serialize each durable child's delivery, release, and disposal. */ -export 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 - } -} - -/** Own the complete process-local lifetime of local and external activations. */ -export class ContinuableActivationRegistry { +/** Own subagent requests and the resources of every live activation. */ +export class SubagentManager { /** Child session id → its live Activation. Process-local, never durable. */ private readonly resident = new Map() /** Root identities retain their pool across child settlement without retaining dead roots. */ @@ -196,9 +131,9 @@ export class ContinuableActivationRegistry { /** Materializations admitted before drain, tracked through publication or rollback. */ private readonly materializations = new Set() /** Per-child serializer shared by delivery, release, and disposal. */ - readonly locks = new ChildLock() + private readonly locks = new ChildLock() /** Structural Cordis owner of every Activation handle. */ - readonly ownerCtx: Context + 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 @@ -209,17 +144,14 @@ export class ContinuableActivationRegistry { private draining = false /** - * Build one registry inside the service's Agent-injected context. + * Build one manager inside the service's Agent-injected context. * @param ctx - context providing Agents, Sessions, and teardown ownership. - * @param observeActivation - build the lifecycle observer for one residency epoch. + * @param host - provider dispatch and lifecycle observation owned by the service. + * @param maxActiveSubagents - the current configured child capacity. */ constructor( private readonly ctx: Context, - private readonly observeActivation: ( - provider: string, - childId: SessionId, - parent: Agent, - ) => ActivationObserver, + private readonly host: SubagentHost, private readonly maxActiveSubagents: () => number, ) { // Ordinary Cordis owner effects unwind in reverse registration order, which @@ -233,91 +165,10 @@ export class ContinuableActivationRegistry { ctx.on('agent/disposed', ({ agent }) => { this.closingScopes.delete(agent) }) - ctx.effect(function* (this: ContinuableActivationRegistry) { + ctx.effect(function* (this: SubagentManager) { yield scope.dispose yield () => this.drain() - }.bind(this), 'subagents.continuations()') - } - - /** - * Return the live Activation for a durable child id, if resident. - * @param childId - durable child session id to look up. - * @returns the process-local Activation, or `undefined` when it is not resident. - */ - get(childId: SessionId): Activation | undefined { - return this.resident.get(childId) - } - - /** - * Require the execution that supports local input and cold-resume semantics. - * @param activation - resident execution targeted by a continuation. - * @returns the local driver. - * @throws when the target is an external execution. - */ - localDriver(activation: Activation): LocalActivationDriver { - const { execution } = activation - switch (execution.kind) { - case 'local': - return execution - case 'external': - throw new SubagentError(`subagent "${activation.childId}" does not accept follow-up input`, 'NOT_CONTINUABLE') - /* v8 ignore next 2 -- Both members of the closed execution union are handled above. */ - default: - return assertNever(execution) - } - } - - /** Admit local input synchronously before any teardown can begin. */ - private deliver(activation: Activation, message: UserMessage, delivery: SubagentDelivery): void { - if (activation.closing !== undefined) { - throw new SubagentError( - 'subagent activation is being disposed; the message was not accepted', - 'ACTIVATION_CLOSING', - ) - } - this.localDriver(activation).deliver(message, delivery) - } - - /** - * Reject one child identity already owned by a live Agent or Session. - * @param childId - proposed durable child session id. - */ - assertChildIdAvailable(childId: SessionId): void { - if (this.resident.has(childId) || this.ctx.agents.get(childId) !== undefined || this.ctx.get('sessions')?.get(childId) !== undefined) { - throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD') - } - } - - /** - * Pre-register `childId` in a continuation-managed parent's owned set so the - * parent cannot settle while a caller is still establishing or resuming that - * child. Returns a releaser for the failure path; it removes only a hold - * this call added, and leaves ownership in place once a live Activation for - * the child exists. - * @param parent - the live direct parent the operation is admitted under. - * @param childId - the durable child the operation addresses. - * @returns the failure-path releaser; a no-op when nothing was added. - */ - holdOwnership(parent: Agent, childId: SessionId): () => void { - const parentActivation = this.resident.get(parent.id) - if (parentActivation === undefined || activationAgent(parentActivation) !== parent) return () => {} - if (parentActivation.closing !== undefined) { - throw new SubagentError( - `subagent parent "${parent.id}" is being disposed; the child was not established`, - 'ACTIVATION_CLOSING', - ) - } - if (parentActivation.ownedChildren.has(childId)) return () => {} - parentActivation.ownedChildren.add(childId) - return () => { - const live = this.resident.get(childId) - /* v8 ignore next 4 -- reaching this arm needs another delivery to establish the child - * between this operation's failure and its releaser running, which no test can schedule - * deterministically: the ownership edge then belongs to that live Activation, so the - * conservative keep leaves it for finishDisposal's releaseOwnership. */ - if (live !== undefined && live.closing === undefined) return - if (parentActivation.ownedChildren.delete(childId)) this.wake(parentActivation) - } + }.bind(this), 'subagents.manager()') } /** @@ -327,9 +178,7 @@ export class ContinuableActivationRegistry { */ interrupt( targetSessionId: SessionId, - authority: - | { readonly kind: 'user'; readonly parentSessionId: SessionId } - | { readonly kind: 'ancestor'; readonly agent: Agent }, + authority: SubagentInterruptAuthority, ): void { if (authority.kind === 'ancestor') { const caller = authority.agent @@ -365,35 +214,15 @@ export class ContinuableActivationRegistry { // a second cancel would be a redundant signal on a closing handle. if (activation.closing !== undefined) return const kind = authority.kind === 'user' ? 'user' : 'parent' - if (activation.execution.kind === 'local') { - activation.execution.cancel(kind, true) + if (activation.kind === 'local') { + activation.handle.agent.cancel({ kind }, { keepInbox: true }) } else { - activation.execution.controller.abort({ kind }) + activation.controller.abort({ kind }) // The settlement observer reports teardown failures. void this.dispose(activation).catch(() => undefined) } } - /** - * Apply activation admission to a resident parent before delivering input. - * @param parent - exact live Agent receiving the message. - * @param message - durable user message to deliver. - * @param delivery - receiving inbox destination. - */ - sendWaking(parent: Agent, message: UserMessage, delivery: SubagentDelivery): void { - const parentActivation = this.resident.get(parent.id) - if (parentActivation !== undefined && activationAgent(parentActivation) === parent) { - try { - this.deliver(parentActivation, message, delivery) - } finally { - this.wake(parentActivation) - } - return - } - if (delivery === 'steer') parent.steer(message) - else parent.followup(message) - } - /** * Close admission, await every already-admitted materialization through * publication or rollback, then dispose the stable live Activation graph @@ -508,10 +337,318 @@ export class ContinuableActivationRegistry { } /** - * Reject new admission once the registry or this exact parent tree began draining. + * Start one continuable background child and resolve at initial inbox acceptance. + * Every earlier failure disposes any created handle and rolls back Activation + * and parent ownership without returning either id. + * @param spec - provider, delegation request, and caller cancellation. + * @returns the durable child id and accepted initial prompt message id. + */ + async startLocal(spec: SubagentActivationSpec): Promise { + const request = spec.request + const parent = request.parent + this.assertAdmitting(parent) + const persistence = spec.delivery === 'caller' ? this.ctx.get('sessionPersistence') : this.requirePersistence() + assertSubagentMaxDepth(request.maxDepth) + const childId = spec.childId ?? brandString(randomUUID()) + this.assertChildIdAvailable(childId) + 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 agentOptions = resolveChildAgentOptions(parent, request.agentOptions, childDepth) + const agentProvider = agentOptions.provider + const agentModel = agentOptions.model + const agentReasoningEffort = agentOptions.reasoningEffort + const descriptor = snapshotSubagentDescriptor({ + mode: 'continuable', + provider: spec.provider, + label: spec.label, + ...agentProvider !== undefined ? { agentProvider } : {}, + ...agentModel !== undefined ? { agentModel } : {}, + ...agentReasoningEffort !== undefined ? { agentReasoningEffort } : {}, + ...request.persona !== undefined ? { persona: request.persona } : {}, + ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, + }) + // Capture before the first await: a later parent switch belongs to the + // parent's future, not to this child. + const delegatedPolicies = captureDelegatedPolicyOverrides(parent) + + // An idle continuation-managed parent must not settle while a caller is + // still creating its child. A turn-scoped delegation does not need this, + // but the service is also callable outside a turn. + const releaseHold = this.holdOwnership(parent, childId) + try { + const prepared = await this.host.prepareContinuable(spec.provider, { + sessionId: childId, + parent, + signal: spec.signal, + }) + spec.signal.throwIfAborted() + this.assertAdmitting(parent) + + const inheritedEventCount = SessionLogOffset(prepared.seed?.length ?? 0) + const seed = prepared.seed + let established!: Activation + const messageId = await this.locks.run(childId, async () => { + spec.signal.throwIfAborted() + this.assertAdmitting(parent) + this.assertChildIdAvailable(childId) + if (spec.childId !== undefined) { + const persisted = await persistence?.stat(childId, { signal: spec.signal }) + spec.signal.throwIfAborted() + this.assertAdmitting(parent) + this.assertChildIdAvailable(childId) + if (persisted !== undefined) { + throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD') + } + } + const activation = await this.materialize({ + kind: 'local', + childId, + provider: spec.provider, + parent, + create: { + seed, + meta: childSessionMeta(parent, childDepth, prepared.seed !== undefined), + inheritedEventCount, + delegatedPolicies, + descriptor, + }, + agentOptions, + composition: { persona: request.persona, toolFilter: request.toolFilter }, + signal: spec.signal, + delivery: spec.delivery, + outputSchema: spec.request.outputSchema, + }) + established = activation + const child = requireLocalActivation(activation).handle.agent + const childHeader = child.session.header + return await this.submitMaterialized( + activation, + spec.delivery !== 'caller' && isAdjacentAgentSendMessageTool(this.ctx.get('tools')?.get('send_message', child)) + ? withContinuableReturnGuidance(parent.id, request.prompt) + : request.prompt, + { source: { kind: 'user' }, signal: spec.signal, delivery: 'queue' }, + parent, + () => { establishCatalogChild(parent.session, childHeader, descriptor) }, + ) + }) + return { ...this.receipt(established), childId, messageId } + } catch (error: unknown) { + releaseHold() + throw error + } + } + + /** + * Start an external backend under activation ownership. + * @param spec - task, provider, cancellation, and delivery policy. + * @returns the accepted activation and its result/disposal capabilities. + */ + async startExternal(spec: SubagentActivationSpec): Promise { + const parent = spec.request.parent + this.assertAdmitting(parent) + const pendingId = brandString(randomUUID()) + const releaseHold = this.holdOwnership(parent, pendingId) + let activation: Activation | undefined + try { + activation = await this.materialize({ + kind: 'external', + childId: pendingId, + provider: spec.provider, + parent, + signal: spec.signal, + delivery: spec.delivery, + start: signal => this.host.startExternal(spec.provider, { + ...spec.request, label: spec.label, signal, + }), + }) + this.assertAdmitting(parent) + this.authorizeLineage(parent, activation.childId, activation.parent.id) + spec.signal.throwIfAborted() + const childId = activation.childId + parent.session.append('subagent/catalog', { + version: 0, childId, childCreatedAt: Date.now(), mode: 'one-shot', label: spec.label, external: true, + }) + this.announce(activation) + return this.receipt(activation) + } catch (error: unknown) { + if (activation !== undefined) { + try { + await this.dispose(activation) + } catch (cleanupError: unknown) { + this.ctx.logger.warn(`subagent "${activation.childId}" admission rollback failed: ${(cleanupError as SubagentError).message}`) + } + } + throw error + } finally { + releaseHold() + } + } + + /** + * Deliver one model-authored message to a direct continuable child or to the + * sender's direct parent. A missing direct child cold-resumes through the + * ordinary continuation lifecycle. + * @param sender - exact live Agent authorizing and originating the message. + * @param targetId - durable direct-parent or direct-child session id. + * @param content - model-authored content to deliver. + * @param options - caller cancellation before acceptance. + * @returns the accepted message's inbox id. + */ + async sendMessage( + sender: Agent, + targetId: SessionId, + content: ContentBlock[], + options: SubagentSendMessageOptions, + ): Promise { + if (this.ctx.agents.get(sender.id) !== sender) { + throw new SubagentError( + 'message delivery requires the exact live sender agent', + 'UNAUTHORIZED', + ) + } + this.assertAdmitting(sender) + const senderActivation = this.resident.get(sender.id) + if (senderActivation !== undefined + && senderActivation.kind === 'local' + && senderActivation.handle.agent === sender + && senderActivation.parent.id === targetId) { + options.signal.throwIfAborted() + return this.sendToParent(senderActivation, sender, content) + } + if (sender.session.header.parentSession === targetId) { + throw new SubagentError( + `agent "${sender.id}" is not a resident continuable child and cannot send to parent "${targetId}"`, + 'UNAUTHORIZED', + ) + } + return this.deliverToChild(sender, targetId, content, { + signal: options.signal, + delivery: 'steer', + }) + } + + /** + * Queue one human-authored prompt as a distinct direct-child turn. + * @param parent - exact live direct parent authorizing delivery. + * @param childId - durable direct-child session id. + * @param content - model-visible prompt blocks. + * @param source - durable attribution for the human prompt. + * @param signal - caller cancellation before inbox acceptance. + * @returns the accepted durable message id. + */ + async queuePrompt( + parent: Agent, + childId: SessionId, + content: ContentBlock[], + source: MessageSource, + signal: AbortSignal, + ): Promise { + return this.deliverToChild(parent, childId, content, { source, signal, delivery: 'queue' }) + } + + /** + * Steer one host-authored prompt to a direct continuable child. + * @param parent - exact live direct parent authorizing delivery. + * @param childId - durable direct-child session id. + * @param content - model-visible prompt blocks. + * @param source - durable attribution for the host prompt. + * @param signal - caller cancellation before inbox acceptance. + * @returns the accepted durable message id. + */ + async steerPrompt( + parent: Agent, + childId: SessionId, + content: ContentBlock[], + source: MessageSource, + signal: AbortSignal, + ): Promise { + return this.deliverToChild(parent, childId, content, { source, signal, delivery: 'steer' }) + } + + /** Admit local input synchronously before any teardown can begin. */ + private deliver(activation: Activation, message: UserMessage, delivery: SubagentDelivery): void { + if (activation.closing !== undefined) { + throw new SubagentError( + 'subagent activation is being disposed; the message was not accepted', + 'ACTIVATION_CLOSING', + ) + } + const local = requireLocalActivation(activation) + if (local.structured?.captured() !== undefined) { + throw new SubagentError('subagent already submitted its structured result; wait for this activation to close before sending another message', 'INPUT_CLOSED') + } + if (delivery === 'steer') local.handle.agent.steer(message) + else local.handle.agent.followup(message) + } + + /** + * Reject one child identity already owned by a live Agent or Session. + * @param childId - proposed durable child session id. + */ + private assertChildIdAvailable(childId: SessionId): void { + if (this.resident.has(childId) || this.ctx.agents.get(childId) !== undefined || this.ctx.get('sessions')?.get(childId) !== undefined) { + throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD') + } + } + + /** + * Pre-register `childId` in a continuation-managed parent's owned set so the + * parent cannot settle while a caller is still establishing or resuming that + * child. Returns a releaser for the failure path; it removes only a hold + * this call added, and leaves ownership in place once a live Activation for + * the child exists. + * @param parent - the live direct parent the operation is admitted under. + * @param childId - the durable child the operation addresses. + * @returns the failure-path releaser; a no-op when nothing was added. + */ + private holdOwnership(parent: Agent, childId: SessionId): () => void { + const parentActivation = this.resident.get(parent.id) + if (parentActivation === undefined || activationAgent(parentActivation) !== parent) return () => {} + if (parentActivation.closing !== undefined) { + throw new SubagentError( + `subagent parent "${parent.id}" is being disposed; the child was not established`, + 'ACTIVATION_CLOSING', + ) + } + if (parentActivation.ownedChildren.has(childId)) return () => {} + parentActivation.ownedChildren.add(childId) + return () => { + const live = this.resident.get(childId) + /* v8 ignore next 4 -- reaching this arm needs another delivery to establish the child + * between this operation's failure and its releaser running, which no test can schedule + * deterministically: the ownership edge then belongs to that live Activation, so the + * conservative keep leaves it for finishDisposal's releaseOwnership. */ + if (live !== undefined && live.closing === undefined) return + if (parentActivation.ownedChildren.delete(childId)) this.wake(parentActivation) + } + } + + /** + * Apply activation admission to a resident parent before delivering input. + * @param parent - exact live Agent receiving the message. + * @param message - durable user message to deliver. + * @param delivery - receiving inbox destination. + */ + private sendWaking(parent: Agent, message: UserMessage, delivery: SubagentDelivery): void { + const parentActivation = this.resident.get(parent.id) + if (parentActivation !== undefined && activationAgent(parentActivation) === parent) { + try { + this.deliver(parentActivation, message, delivery) + } finally { + this.wake(parentActivation) + } + return + } + if (delivery === 'steer') parent.steer(message) + else parent.followup(message) + } + + /** + * Reject new admission once the manager or this exact parent tree began draining. * @param agent - exact live Agent whose lineage determines admission. */ - assertAdmitting(agent: Agent): void { + private assertAdmitting(agent: Agent): void { const closing = this.closingTeardownFor(agent) if (closing === undefined) return throw new SubagentError( @@ -528,7 +665,7 @@ export class ContinuableActivationRegistry { * @param childId - durable child session id addressed by the operation. * @param parentSession - durable direct-parent id recorded by the child. */ - authorizeLineage( + private authorizeLineage( parent: Agent, childId: SessionId, parentSession: SessionId | undefined, @@ -549,7 +686,7 @@ export class ContinuableActivationRegistry { * @param inputs - reconstruction and admission inputs for the residency epoch. * @returns the published process-local Activation. */ - materialize(inputs: MaterializeInputs): Promise { + private materialize(inputs: MaterializeInputs): Promise { this.assertAdmitting(inputs.parent) inputs.signal.throwIfAborted() const lineage = this.liveLineage(inputs.parent) @@ -575,44 +712,12 @@ export class ContinuableActivationRegistry { }) } - /** - * Cross the final admission cutoff and submit without yielding. - * @param activation - the exact resident child receiving the message. - * @param message - the already-built durable user message. - * @param delivery - the Agent inbox destination. - * @param parent - exact live direct parent authorizing admission. - * @param signal - caller cancellation before inbox acceptance. - * @returns the accepted durable message id. - */ - submitAdmitted( - activation: Activation, - message: UserMessage, - delivery: SubagentDelivery, - parent: Agent, - signal: AbortSignal, - ): MessageId { - signal.throwIfAborted() - this.assertAdmitting(parent) - this.authorizeLineage( - parent, - activation.childId, - activation.parent.id, - ) - this.acquireOwnership(parent, activation.childId) - try { - this.deliver(activation, message, delivery) - } finally { - this.wake(activation) - } - return message.id - } - /** * Stop and release one Activation through its memoized close transaction. * @param activation - exact residency epoch to close. * @returns the shared close transaction. */ - dispose(activation: Activation): Promise { + private dispose(activation: Activation): Promise { return this.close(activation, () => this.finishDisposal(activation, true)) } @@ -699,30 +804,17 @@ export class ContinuableActivationRegistry { pool: ActivationPool, releaseSlot: () => void, ): Promise { - const { provider, parent, create } = inputs + const { provider, parent } = inputs let childId = inputs.childId - let structured: StructuredAttachment | undefined inputs.signal.throwIfAborted() - const setup = (childCtx: Context, child: Agent): void => { - // Only fresh creation appends the descriptor and delegated policy after - // the inherited marker; a cold resume replays those persisted events. - if (create !== undefined) { - child.session.append('subagent/descriptor', create.descriptor) - appendDelegatedPolicyOverrides(child.session, create.delegatedPolicies) - } - applyChildComposition(childCtx, parent, inputs.composition) - if (inputs.outputSchema !== undefined) { - structured = attachStructuredRuntime(childCtx, inputs.outputSchema, () => this.resident.get(childId)?.ownedChildren.size === 0) - } - } - let execution: Activation['execution'] - if (inputs.external !== undefined) { + let resource: ActivationResource + if (inputs.kind === 'external') { const controller = new AbortController() const cancelStartup = (): void => { controller.abort(inputs.signal.reason) } inputs.signal.addEventListener('abort', cancelStartup, { once: true }) try { - const run = await inputs.external(controller.signal) - execution = { kind: 'external', run, controller } + const run = await inputs.start(controller.signal) + resource = { kind: 'external', run, controller } void run.result.catch(() => undefined) childId = run.id try { this.assertChildIdAvailable(childId) } catch (error: unknown) { @@ -733,6 +825,20 @@ export class ContinuableActivationRegistry { inputs.signal.removeEventListener('abort', cancelStartup) } } else { + const { create } = inputs + let structured: StructuredAttachment | undefined + const setup = (childCtx: Context, child: Agent): void => { + // Only fresh creation appends the descriptor and delegated policy after + // the inherited marker; a cold resume replays those persisted events. + if (create !== undefined) { + child.session.append('subagent/descriptor', create.descriptor) + appendDelegatedPolicyOverrides(child.session, create.delegatedPolicies) + } + applyChildComposition(childCtx, parent, inputs.composition) + if (inputs.outputSchema !== undefined) { + structured = attachStructuredRuntime(childCtx, inputs.outputSchema, () => this.resident.get(childId)?.ownedChildren.size === 0) + } + } const handle = create === undefined ? await this.ownerCtx.agents.resume({ resumeSessionId: childId, @@ -752,24 +858,23 @@ export class ContinuableActivationRegistry { setup, }) - execution = new LocalActivationDriver(handle, structured) + resource = { kind: 'local', handle, outputStart: handle.agent.session.seq, structured, poke: Promise.withResolvers() } } - const observer = this.observeActivation(provider, childId, parent) + const observer = this.host.observeActivation(provider, childId, parent) const activation: Activation = { pool, releaseSlot, childId, - execution, + ...resource, parent, delivery: inputs.delivery ?? 'parent', result: Promise.withResolvers(), closing: undefined, - ancestry: new WeakSet(execution.kind === 'external' ? parentLineage : [execution.agent, ...parentLineage]), + ancestry: new WeakSet(resource.kind === 'external' ? parentLineage : [resource.handle.agent, ...parentLineage]), ownedChildren: new Set(), observer, announced: false, - poke: Promise.withResolvers(), } void activation.result.promise.catch(() => undefined) this.resident.set(childId, activation) @@ -779,7 +884,10 @@ export class ContinuableActivationRegistry { if (this.ctx.agents.get(parent.id) !== parent) throw new SubagentError('subagent parent is no longer live', 'UNAUTHORIZED') this.acquireOwnership(parent, childId) const wakeOnInboxRemoval = (): void => { this.wake(activation) } - if (execution.kind === 'local') execution.onInputRemoved(wakeOnInboxRemoval) + if (activation.kind === 'local') { + activation.handle.agent.ctx.on('agent/inbox/claimed', wakeOnInboxRemoval) + activation.handle.agent.ctx.on('agent/inbox/discarded', wakeOnInboxRemoval) + } observer.start(activationAgent(activation)) } catch (error: unknown) { /* v8 ignore next -- rollback failure must not mask the admission failure @@ -794,15 +902,14 @@ export class ContinuableActivationRegistry { * Begin settlement observation after initial acceptance is recorded. * @param activation - accepted activation to observe. */ - announce(activation: Activation): void { + private announce(activation: Activation): void { if (activation.announced) return activation.announced = true - const { execution } = activation - if (execution.kind === 'local') { - this.watchLocalSettlement(activation, execution) + if (activation.kind === 'local') { + this.watchLocalSettlement(activation) } else { const finish = (): Promise => this.close(activation, () => this.finishDisposal(activation, false)) - void execution.run.result.then(finish, finish).catch((error: unknown) => { this.reportTeardownFailure(activation, error) }) + void activation.run.result.then(finish, finish).catch((error: unknown) => { this.reportTeardownFailure(activation, error) }) } } @@ -810,7 +917,7 @@ export class ContinuableActivationRegistry { private rollbackUnpublished(activation: Activation, error: unknown): Promise { return this.close(activation, async () => { try { - await (activation.execution.kind === 'local' ? activation.execution.dispose() : activation.execution.run.dispose()) + await (activation.kind === 'local' ? activation.handle.dispose() : activation.run.dispose()) } finally { this.resident.delete(activation.childId) activation.releaseSlot() @@ -842,19 +949,20 @@ export class ContinuableActivationRegistry { /** Let a settlement watcher re-check residency after relevant state changes. */ private wake(activation: Activation): void { + if (activation.kind === 'external') return activation.poke.resolve() activation.poke = Promise.withResolvers() } /** Follow one Activation to natural settlement. */ - private watchLocalSettlement(activation: Activation, local: LocalActivationDriver): void { + private watchLocalSettlement(activation: LocalActivation): void { void (async () => { while (true) { const idleObservation = activation.poke - await local.whenIdle() + await activation.handle.agent.whenIdle() if (activation.closing !== undefined) return const readiness = await this.locks.run(activation.childId, () => Promise.resolve( - this.settlementState(activation, local, idleObservation), + this.settlementState(activation, idleObservation), )) if (readiness === 'closed') return if (readiness === 'retry') continue @@ -863,18 +971,22 @@ export class ContinuableActivationRegistry { continue } - const finalSeq = local.version - await this.flushFinalState(activation, local) + const finalSeq = activation.handle.agent.session.seq + await this.flushFinalState(activation) const attempt = await this.locks.run(activation.childId, () => { - const state = this.settlementState(activation, local, idleObservation) + const state = this.settlementState(activation, idleObservation) if (state !== 'ready') return Promise.resolve(state) - if (local.version !== finalSeq) { + if (activation.handle.agent.session.seq !== finalSeq) { return Promise.resolve('retry') } let done!: Promise - if (!local.closeWhenIdle(() => { - done = this.close(activation, () => this.finishDisposal(activation, false)) - })) { + try { + void activation.handle.agent.runMaintenance(() => { + done = this.close(activation, () => this.finishDisposal(activation, false)) + return Promise.resolve() + }) + } catch (_error: unknown) { + // Another activity already owns the Agent's idle phase. return Promise.resolve('retry') } return Promise.resolve({ done }) @@ -896,19 +1008,20 @@ export class ContinuableActivationRegistry { })() } + /** Report disposal failure once after the natural settlement observer finishes. */ private reportTeardownFailure(activation: Activation, error: unknown): void { this.ctx.logger.warn(`subagent "${activation.childId}" activation teardown failed: ${failureMessage(error)}`) } /** Check pending input and owned children before closing admission. */ private settlementState( - activation: Activation, - local: LocalActivationDriver, + activation: LocalActivation, observation: PromiseWithResolvers, ): SettlementState { if (activation.closing !== undefined) return 'closed' if (activation.poke !== observation) return 'retry' - if (local.hasPending || activation.ownedChildren.size > 0) return 'wait' + const inbox = activation.handle.agent.inbox + if (inbox.nextTurn.length > 0 || inbox.nextStep.length > 0 || activation.ownedChildren.size > 0) return 'wait' return 'ready' } @@ -919,7 +1032,6 @@ export class ContinuableActivationRegistry { const failures: SubagentError[] = [] let result: SubagentResult = { output: [], stopReason: 'error' } let resultFailure: { error: unknown } | undefined - const { execution } = activation let externalDisposal: Promise | undefined const child = activationAgent(activation) const includeOutput = child === undefined @@ -928,18 +1040,18 @@ export class ContinuableActivationRegistry { ? failureMessage(error) : errorChain(error) try { - if (execution.kind === 'external') { + if (activation.kind === 'external') { if (stop) { - execution.controller.abort({ kind: 'parent' }) + activation.controller.abort({ kind: 'parent' }) // Some backends settle their result only when disposal begins. - externalDisposal = Promise.resolve().then(() => execution.run.dispose()) + externalDisposal = Promise.resolve().then(() => activation.run.dispose()) void externalDisposal.catch(() => undefined) } - result = await execution.run.result + result = await activation.run.result } else { if (stop) { - execution.cancel('parent') - const idle = execution.whenIdle() + activation.handle.agent.cancel({ kind: 'parent' }) + const idle = activation.handle.agent.whenIdle() const children = [...activation.ownedChildren] .map(child => this.resident.get(child)) .filter((child): child is Activation => child !== undefined) @@ -960,9 +1072,9 @@ export class ContinuableActivationRegistry { )) } await idle - await this.flushFinalState(activation, execution) + await this.flushFinalState(activation) } - result = execution.capture() + result = captureLocalResult(activation) } } catch (error: unknown) { resultFailure = { error } @@ -973,8 +1085,8 @@ export class ContinuableActivationRegistry { )) } try { - if (execution.kind === 'local') await execution.dispose() - else await (externalDisposal ?? execution.run.dispose()) + if (activation.kind === 'local') await activation.handle.dispose() + else await (externalDisposal ?? activation.run.dispose()) } catch (error: unknown) { failures.push(new SubagentError( `subagent "${childId}" activation handle disposal failed: ${detail(error)}`, @@ -1028,9 +1140,9 @@ export class ContinuableActivationRegistry { } /** Request a best-effort final session flush before closing natural-settlement admission. */ - private async flushFinalState(activation: Activation, local: LocalActivationDriver): Promise { + private async flushFinalState(activation: LocalActivation): Promise { try { - await local.flush() + await activation.handle.agent.ctx.sessions.flush(activation.handle.agent.session) } catch (error: unknown) { this.ctx.logger.warn( `subagent "${activation.childId}" best-effort final session flush failed; ` @@ -1038,4 +1150,264 @@ export class ContinuableActivationRegistry { ) } } + + /** Return capabilities bound to an exact activation instead of a future same-id instance. */ + private receipt(activation: Activation): SubagentActivation { + return { + childId: activation.childId, + result: activation.result.promise, + dispose: () => this.dispose(activation), + } + } + + /** Route one parent-originated delivery through residency and cold resume. */ + private async deliverToChild( + parent: Agent, + childId: SessionId, + content: ContentBlock[], + options: ChildDeliveryOptions, + ): Promise { + this.assertAdmitting(parent) + const releaseHold = this.holdOwnership(parent, childId) + try { + return await this.deliverFollowup(parent, childId, content, options) + } catch (error: unknown) { + releaseHold() + throw error + } + } + + /** The delivery loop behind {@link deliverToChild}, run under the parent hold. */ + private async deliverFollowup( + parent: Agent, + childId: SessionId, + content: ContentBlock[], + options: ChildDeliveryOptions, + ): Promise { + while (true) { + const live = await this.locks.run(childId, async () => { + const activation = this.resident.get(childId) + if (activation === undefined) return this.coldResume(parent, childId, content, options) + requireLocalActivation(activation) + const disposal = activation.closing + /* v8 ignore next 3 -- the send-versus-dispose cutoff needs a delivery to + * observe the transaction inside the same critical section that opened it. */ + if (disposal !== undefined) { + return disposal.then(() => undefined, () => undefined) + } + if (contentHasImage(content)) { + await this.assertImageCapable(requireLocalActivation(activation).handle.agent, options.signal) + if (activation.closing !== undefined) { + await Promise.allSettled([activation.closing]) + return undefined + } + } + const messageId = this.submitAdmitted(activation, content, options, parent) + this.announce(activation) + return messageId + }) + /* v8 ignore start -- only a delivery that lost the disposal cutoff retries. */ + if (live !== undefined) return live + this.assertAdmitting(parent) + options.signal.throwIfAborted() + /* v8 ignore stop */ + } + } + + /** Deliver one resident continuable child's message to its live direct parent. */ + private sendToParent( + activation: Activation, + sender: Agent, + content: ContentBlock[], + ): MessageId { + /* v8 ignore next 6 -- only synchronous re-entrant teardown can open this + * transaction between exact-agent authorization and this no-await span. */ + if (activation.closing !== undefined) { + throw new SubagentError( + `subagent "${sender.id}" activation is being disposed; the message was not delivered`, + 'ACTIVATION_CLOSING', + ) + } + const parent = this.ctx.agents.get(activation.parent.id) + if (parent !== activation.parent) { + throw new SubagentError( + 'direct parent is not live; the message was not delivered', + 'PARENT_UNAVAILABLE', + ) + } + const message = createAgentMessage(sender, content) + this.sendAgentMessage(parent, message) + return message.id + } + + /** Send one Agent message while translating only the target's own rejection. */ + private sendAgentMessage( + parent: Agent, + message: ReturnType, + ): void { + try { + this.sendWaking(parent, message, 'steer') + } catch (error: unknown) { + throw new SubagentError( + 'direct parent is not live; the message was not delivered', + 'PARENT_UNAVAILABLE', + { cause: error }, + ) + } + } + + /** + * Cold-resume a persisted child and submit the waiting turn. The descriptor + * supplies every reconstruction input; no subagent provider is dispatched. + */ + private async coldResume( + parent: Agent, + childId: SessionId, + content: ContentBlock[], + options: ChildDeliveryOptions, + ): Promise { + const query = this.requireSessionQuery() + let observation: SessionObservation + try { + observation = await query.observeSession(childId, { + signal: options.signal, + }) + } catch (error: unknown) { + options.signal.throwIfAborted() + throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) + } + using source = observation + this.assertAdmitting(parent) + this.authorizeLineage(parent, childId, source.header.parentSession) + const descriptor = foldSubagentDescriptor( + source.events.slice(source.inheritedEventCount), + ) + if (descriptor === undefined || descriptor.mode !== 'continuable') { + throw new SubagentError( + `subagent "${childId}" has no supported continuation state and cannot be resumed; choose a different target`, + 'NOT_RESUMABLE', + ) + } + let activation: Activation + try { + activation = await this.materialize({ + kind: 'local', + childId, + provider: descriptor.provider, + parent, + agentOptions: { + ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, + ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, + ...descriptor.agentReasoningEffort !== undefined + ? { reasoningEffort: ReasoningEffortId(descriptor.agentReasoningEffort) } + : {}, + }, + composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, + signal: options.signal, + }) + } catch (error: unknown) { + options.signal.throwIfAborted() + if (error instanceof SubagentError) throw error + throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) + } + return await this.submitMaterialized(activation, content, options, parent) + } + + /** Admit a materialized child, commit its creation fact, and release it on failure. */ + private async submitMaterialized( + activation: Activation, + content: ContentBlock[], + options: ChildDeliveryOptions, + parent: Agent, + commit?: () => void, + ): Promise { + try { + if (contentHasImage(content)) { + await this.assertImageCapable(requireLocalActivation(activation).handle.agent, options.signal) + if (activation.closing !== undefined) { + throw new SubagentError(`subagent "${activation.childId}" is closing`, 'ACTIVATION_CLOSING') + } + } + const messageId = this.submitAdmitted(activation, content, options, parent) + commit?.() + this.announce(activation) + return messageId + } catch (error: unknown) { + try { + await this.dispose(activation) + } catch (cleanupError: unknown) { + this.ctx.logger.warn( + `subagent continuation: disposal after admission or catalog append failure also failed: ${String(cleanupError)}`, + ) + } + throw error + } + } + + /** Build and submit one message across the final synchronous admission cutoff. */ + private submitAdmitted( + activation: Activation, + content: ContentBlock[], + options: ChildDeliveryOptions, + parent: Agent, + ): MessageId { + const message = options.source === undefined + ? createAgentMessage(parent, content) + : createUserMessage({ content, source: options.source }) + options.signal.throwIfAborted() + this.assertAdmitting(parent) + this.authorizeLineage(parent, activation.childId, activation.parent.id) + this.acquireOwnership(parent, activation.childId) + try { + this.deliver(activation, message, options.delivery) + } finally { + this.wake(activation) + } + return message.id + } + + /** Refuse image content for a child whose fixed model accepts text only. */ + private async assertImageCapable( + agent: Agent, + signal: AbortSignal, + ): Promise { + const { provider, model } = agent.options + if (provider === undefined || model === undefined) return + const llm = this.ctx.get('llm') + /* v8 ignore next -- without an LLM registry, delivery defers to projection. */ + if (llm === undefined) return + const info = await llm.resolveModelInfo(provider, model, signal) + if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) { + throw new SubagentError( + `Model "${model}" does not support image input.`, + 'MODEL_DOES_NOT_SUPPORT_IMAGES', + ) + } + } + + /** Resolve the persistence service continuable children require, or fail loud. */ + private requirePersistence(): SessionPersistence { + const persistence = this.ctx.get('sessionPersistence') + if (persistence === undefined) { + throw new SubagentError( + 'continuable subagents require session persistence (load a dsh-session-persistence backend)', + 'PERSISTENCE_UNAVAILABLE', + ) + } + return persistence + } + + /** Resolve the Session query service used for cold child observations. */ + private requireSessionQuery(): SessionQueryEngine { + const query = this.ctx.get('sessionQuery') + if (query === undefined) { + throw new SubagentError( + 'continuable subagents require session query (load @deepseek-ai/dsh-session-query)', + 'CONTINUATION_UNAVAILABLE', + ) + } + return query + } } + +export default SubagentManager diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 6d3a05d7cb..24e4b383f0 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -3,7 +3,7 @@ * 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 + * manager host in `./manager.ts` — so this module stays the published * surface rather than a bag of everything type-shaped. * * @module @deepseek-ai/dsh-subagent/types diff --git a/packages/subagent/subagent/tests/continuation-internals.ts b/packages/subagent/subagent/tests/continuation-internals.ts deleted file mode 100644 index ba45872701..0000000000 --- a/packages/subagent/subagent/tests/continuation-internals.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** Package-private continuation owners used to place deterministic lifecycle races. */ - -import type { Context } from '@deepseek-ai/cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' -import type { Activation, ContinuableActivationRegistry } from '../src/continuation-activation.ts' -import type SubagentContinuationManager from '../src/continuation.ts' - -/** Return the service's bound continuation manager. */ -export function continuationManager(ctx: Context): SubagentContinuationManager { - const manager = (ctx.subagents as unknown as { - continuations?: SubagentContinuationManager - }).continuations - if (manager === undefined) throw new Error('expected a bound continuation manager') - return manager -} - -/** Return the manager's sole process-local Activation owner. */ -export function continuationActivations(ctx: Context): ContinuableActivationRegistry { - return (continuationManager(ctx) as unknown as { - activations: ContinuableActivationRegistry - }).activations -} - -/** Remove only the registry entry, leaving its Agent live for collision coverage. */ -export function dropContinuationActivation(ctx: Context, childId: SessionId): void { - const registry = continuationActivations(ctx) as unknown as { - resident: Map - } - registry.resident.delete(childId) -} diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 52274f68dc..c5849ebcb2 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -25,14 +25,16 @@ import SubagentRuntime, { } from '../src/index.ts' import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts' import type { SubagentPromptRequestId } from '../src/control-types.ts' +import * as activationResults from '../src/activation.ts' +import { requireLocalActivation } from '../src/activation.ts' import * as SubagentInvariant from '../src/invariant.ts' import { TestSessionQuery } from './test-session-query.ts' import { loadStoredSession } from './persistence-helpers.ts' import { - continuationActivations, - continuationManager, - dropContinuationActivation, -} from './continuation-internals.ts' + managerState, + subagentManager, + dropActivation, +} from './manager-internals.ts' /** Writable settings isolated to one test Context. */ class MemorySettings extends SettingsProvider { @@ -160,7 +162,7 @@ function queuePrompt( content: ContentBlock[], signal: AbortSignal = testSignal, ) { - return continuationManager(ctx).queuePrompt(parent, childId, content, { kind: 'user' }, signal) + return subagentManager(ctx).queuePrompt(parent, childId, content, { kind: 'user' }, signal) } function humanPrompt( @@ -185,7 +187,7 @@ function humanPrompt( * adding the irreversible operation to the public service contract. */ function drainManager(ctx: Context): Promise { - return continuationManager(ctx).drain() + return subagentManager(ctx).drain() } /** Wait until a child's Activation is gone, i.e. its handle finished disposal. */ @@ -210,9 +212,9 @@ async function passSettlementCheck(ctx: Context, childId: SessionId): Promise Promise.resolve()) } -/** The Activation registry's package-private lock, which orders every child decision. */ +/** The manager's package-private lock, which orders every child decision. */ function childLocks(ctx: Context) { - return continuationActivations(ctx) + return managerState(ctx) } /** @@ -365,7 +367,7 @@ describe('continuable activation capacity', () => { parkParent(ctx, child) releaseParent.resolve(undefined) await child.whenIdle() - expect(continuationActivations(ctx).get(first.childId)).toBeDefined() + expect(managerState(ctx).resident.get(first.childId)).toBeDefined() await expect(ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })) .rejects.toMatchObject({ code: 'ACTIVATION_LIMIT_REACHED' }) release.resolve(undefined) @@ -380,7 +382,7 @@ describe('continuable activation capacity', () => { it('reserves the last slot before asynchronous creation and returns it after failure', async () => { const { ctx, parent } = await setupWith(new MockAdapter([textResponse('replacement')]), { maxActiveSubagents: 1 }) parkParent(ctx, parent) - const agents = continuationActivations(ctx).ownerCtx.agents + const agents = managerState(ctx).ownerCtx.agents const entered = Promise.withResolvers() const release = Promise.withResolvers() const createSpy = vi.spyOn(agents, 'create').mockImplementationOnce(async () => { @@ -481,10 +483,10 @@ describe('continuable activation capacity', () => { try { const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - const activation = continuationActivations(ctx).get(started.childId)! - const driver = continuationActivations(ctx).localDriver(activation) - const dispose = driver.dispose.bind(driver) - driver.dispose = async () => { + const activation = managerState(ctx).resident.get(started.childId)! + const handle = requireLocalActivation(activation).handle + const dispose = handle.dispose.bind(handle) + handle.dispose = async () => { entered.resolve(undefined) await releaseDisposal.promise await dispose() @@ -692,11 +694,11 @@ describe('SubagentRuntime.startActivation', () => { ) => SessionEvent<'subagent/catalog'> vi.spyOn(parent.session, 'append').mockImplementation(((type: string, data: unknown) => { if (type === 'subagent/catalog') { - const activation = continuationActivations(ctx).get(childId) + const activation = managerState(ctx).resident.get(childId) if (activation === undefined) throw new Error('expected live Activation') - const driver = continuationActivations(ctx).localDriver(activation) - const dispose = driver.dispose.bind(driver) - vi.spyOn(driver, 'dispose').mockImplementation(async () => { + const handle = requireLocalActivation(activation).handle + const dispose = handle.dispose.bind(handle) + vi.spyOn(handle, 'dispose').mockImplementation(async () => { await dispose() throw cleanupFailure }) @@ -724,11 +726,11 @@ describe('SubagentRuntime.startActivation', () => { const warnings: string[] = [] ctx.logger.warn = (message: string) => { warnings.push(message) } ctx.on('subagent/start', () => { - const activation = continuationActivations(ctx).get(childId) + const activation = managerState(ctx).resident.get(childId) if (activation === undefined) throw new Error('expected live Activation') - const driver = continuationActivations(ctx).localDriver(activation) - const dispose = driver.dispose.bind(driver) - vi.spyOn(driver, 'dispose').mockImplementation(async () => { + const handle = requireLocalActivation(activation).handle + const dispose = handle.dispose.bind(handle) + vi.spyOn(handle, 'dispose').mockImplementation(async () => { await dispose() throw cleanupFailure }) @@ -1625,10 +1627,10 @@ describe('continuable durability and teardown', () => { const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - const activation = continuationActivations(ctx).get(started.childId)! - const driver = continuationActivations(ctx).localDriver(activation) - const realDispose = driver.dispose.bind(driver) - driver.dispose = async () => { + const activation = managerState(ctx).resident.get(started.childId)! + const handle = requireLocalActivation(activation).handle + const realDispose = handle.dispose.bind(handle) + handle.dispose = async () => { await realDispose() throw new Error('normal settlement cleanup failed') } @@ -1813,10 +1815,10 @@ describe('continuable durability and teardown', () => { const { ctx, parent } = await setupWith(adapter) const target = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - const activation = continuationActivations(ctx).get(target.childId)! - const driver = continuationActivations(ctx).localDriver(activation) - const realDispose = driver.dispose.bind(driver) - driver.dispose = async () => { + const activation = managerState(ctx).resident.get(target.childId)! + const handle = requireLocalActivation(activation).handle + const realDispose = handle.dispose.bind(handle) + handle.dispose = async () => { await realDispose() throw new Error('selected cleanup failed') } @@ -1897,7 +1899,7 @@ describe('continuable durability and teardown', () => { it('awaits and rolls back an admitted materialization below a scoped root', async () => { const { ctx, parent } = await setup([]) - const agents = continuationActivations(ctx).ownerCtx.agents + const agents = managerState(ctx).ownerCtx.agents const create = agents.create.bind(agents) const published = Promise.withResolvers() const releaseMaterialization = Promise.withResolvers() @@ -1945,10 +1947,10 @@ describe('continuable durability and teardown', () => { const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - const activation = continuationActivations(ctx).get(started.childId)! - const driver = continuationActivations(ctx).localDriver(activation) - const realDispose = driver.dispose.bind(driver) - driver.dispose = async () => { + const activation = managerState(ctx).resident.get(started.childId)! + const handle = requireLocalActivation(activation).handle + const realDispose = handle.dispose.bind(handle) + handle.dispose = async () => { await realDispose() throw new Error('scoped child reap failed') } @@ -2069,7 +2071,7 @@ describe('continuable review regressions', () => { const started = await ctx.subagents.startActivation({ ...startSpec(originalParent.agent), delivery: 'parent' }) await waitNoActivation(ctx, started.childId) - const ownerAgents = continuationActivations(ctx).ownerCtx.agents + const ownerAgents = managerState(ctx).ownerCtx.agents const originalResume = ownerAgents.resume.bind(ownerAgents) const resumed = Promise.withResolvers() const releaseResume = Promise.withResolvers() @@ -2262,10 +2264,10 @@ describe('continuable review regressions', () => { const ended = vi.fn() ctx.on('subagent/end', ended) const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) - const activation = continuationActivations(ctx).get(started.childId)! - const driver = continuationActivations(ctx).localDriver(activation) - const realDispose = driver.dispose.bind(driver) - driver.dispose = async () => { + const activation = managerState(ctx).resident.get(started.childId)! + const handle = requireLocalActivation(activation).handle + const realDispose = handle.dispose.bind(handle) + handle.dispose = async () => { cleaning.resolve(undefined) await cleanup.promise await realDispose() @@ -2287,7 +2289,7 @@ describe('continuable review regressions', () => { await expect(started.result).resolves.toMatchObject({ stopReason: 'completed' }) expect(ended).toHaveBeenCalledTimes(1) expect(followup.mock.calls.length + steer.mock.calls.length).toBe(1) - expect(continuationActivations(ctx).get(started.childId)).toBeUndefined() + expect(managerState(ctx).resident.get(started.childId)).toBeUndefined() await expect(waiting).resolves.toBe(true) }) @@ -2298,13 +2300,13 @@ describe('continuable review regressions', () => { const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const activation = await vi.waitFor(() => { - const found = continuationActivations(ctx).get(started.childId) + const found = managerState(ctx).resident.get(started.childId) expect(found).toBeDefined() return found! }) - const driver = continuationActivations(ctx).localDriver(activation) - const realDispose = driver.dispose.bind(driver) - driver.dispose = async () => { + const handle = requireLocalActivation(activation).handle + const realDispose = handle.dispose.bind(handle) + handle.dispose = async () => { await realDispose() throw new Error('scoped cleanup failed') } @@ -2322,16 +2324,19 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', info => void ends.push(info)) - const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) - const activation = continuationActivations(ctx).get(started.childId)! - const driver = continuationActivations(ctx).localDriver(activation) - driver.capture = () => { throw new Error('capture failed') } + await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) + const capture = vi.spyOn(activationResults, 'captureLocalResult').mockImplementationOnce(() => { throw new Error('capture failed') }) - const drained = drainManager(ctx) - 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') + try { + const drained = drainManager(ctx) + 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') + } finally { + capture.mockRestore() + hold.resolve(undefined) + } }) it('releases a naturally settled Activation when terminal capture fails', async () => { @@ -2342,15 +2347,19 @@ describe('continuable review regressions', () => { ctx.on('subagent/end', info => void ends.push(info)) const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) - const driver = continuationActivations(ctx).localDriver(continuationActivations(ctx).get(started.childId)!) - driver.capture = () => { + const capture = vi.spyOn(activationResults, 'captureLocalResult').mockImplementationOnce(() => { throw new Error('capture failed') - } + }) - hold.resolve(undefined) - await waitNoActivation(ctx, started.childId) - await vi.waitFor(() => { expect(ends).toHaveLength(1) }) - expect(ends[0]!.stopReason).toBe('error') + try { + hold.resolve(undefined) + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + expect(ends[0]!.stopReason).toBe('error') + } finally { + capture.mockRestore() + hold.resolve(undefined) + } }) it('preserves independent pre-disposal and handle-disposal failures', async () => { @@ -2358,23 +2367,28 @@ describe('continuable review regressions', () => { const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) - const activation = continuationActivations(ctx).get(started.childId)! - const driver = continuationActivations(ctx).localDriver(activation) - const realDispose = driver.dispose.bind(driver) - driver.capture = () => { throw new Error('capture failed') } - driver.dispose = async () => { + const activation = managerState(ctx).resident.get(started.childId)! + const handle = requireLocalActivation(activation).handle + const realDispose = handle.dispose.bind(handle) + const capture = vi.spyOn(activationResults, 'captureLocalResult').mockImplementationOnce(() => { throw new Error('capture failed') }) + handle.dispose = async () => { await realDispose() throw new Error('scoped cleanup failed') } - const drained = drainManager(ctx) - hold.resolve(undefined) - const failure = await drained.catch((error: unknown) => error) + try { + const drained = drainManager(ctx) + hold.resolve(undefined) + const failure = await drained.catch((error: unknown) => error) - expect(failure).toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) - expect(String(failure)).toContain('capture failed') - expect(String(failure)).toContain('scoped cleanup failed') - expect(ctx.agents.get(started.childId)).toBeUndefined() + expect(failure).toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) + expect(String(failure)).toContain('capture failed') + expect(String(failure)).toContain('scoped cleanup failed') + expect(ctx.agents.get(started.childId)).toBeUndefined() + } finally { + capture.mockRestore() + hold.resolve(undefined) + } }) it('cancels a running turn before the best-effort final flush', async () => { @@ -2443,10 +2457,10 @@ describe('continuable review regressions', () => { const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: turn.promise }]) const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'caller' }) - const activation = continuationActivations(ctx).get(started.childId)! + const activation = managerState(ctx).resident.get(started.childId)! const child = ctx.agents.get(started.childId)! - const driver = continuationActivations(ctx).localDriver(activation) - const disposed = vi.spyOn(driver, 'dispose') + const handle = requireLocalActivation(activation).handle + const disposed = vi.spyOn(handle, 'dispose') const ended = vi.fn() ctx.on('subagent/end', ended) const lock = await holdChildLock(ctx, started.childId) @@ -2462,7 +2476,7 @@ describe('continuable review regressions', () => { await expect(started.result).resolves.toMatchObject({ stopReason: 'completed' }) expect(disposed).toHaveBeenCalledTimes(1) expect(ended).toHaveBeenCalledTimes(1) - expect(continuationActivations(ctx).get(started.childId)).toBeUndefined() + expect(managerState(ctx).resident.get(started.childId)).toBeUndefined() }) it('keeps a maintenance task that claimed the idle phase after whenIdle resolved', async () => { @@ -2548,7 +2562,7 @@ describe('continuable review regressions', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! // A cordis-host-runner failure report: `steer()` from a plugin still wakes - // a driver, so residency must survive until that turn claims the message. + // a handle, so residency must survive until that turn claims the message. const steered = createUserMessage({ content: message('Cordis Host handler failed'), source: { kind: 'cordis-host-runner' }, @@ -3048,13 +3062,13 @@ describe('continuable settlement delivery', () => { const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')]) const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) const activation = await vi.waitFor(() => { - const live = continuationActivations(ctx).get(started.childId) + const live = managerState(ctx).resident.get(started.childId) expect(live).toBeDefined() return live! }) - const driver = continuationActivations(ctx).localDriver(activation) - const dispose = driver.dispose.bind(driver) - driver.dispose = async () => { + const handle = requireLocalActivation(activation).handle + const dispose = handle.dispose.bind(handle) + handle.dispose = async () => { await dispose() throw new Error('scope unwind failed') } @@ -3175,7 +3189,7 @@ describe('continuable settlement delivery', () => { let ownedAtDelivery: SessionId[] | undefined ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (agent !== middle || message.source.kind !== 'subagent-settled') return - ownedAtDelivery = [...continuationActivations(ctx).get(middle.id)!.ownedChildren] + ownedAtDelivery = [...managerState(ctx).resident.get(middle.id)!.ownedChildren] }) releaseChild.resolve(undefined) @@ -3424,7 +3438,7 @@ describe('continuable errors', () => { }) // Drop the Activation without disposing the Agent, leaving the id live but // unmanaged. Materialization must not adopt it. - dropContinuationActivation(ctx, started.childId) + dropActivation(ctx, started.childId) await expect(queuePrompt(ctx, parent, started.childId, message('hello'))) .rejects.toThrow(SubagentError) @@ -3484,10 +3498,10 @@ describe('continuable errors', () => { 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 branch = continuationActivations(ctx).get(grandchild.childId)! - const driver = continuationActivations(ctx).localDriver(branch) - const realDispose = driver.dispose.bind(driver) - driver.dispose = async () => { + const branch = managerState(ctx).resident.get(grandchild.childId)! + const handle = requireLocalActivation(branch).handle + const realDispose = handle.dispose.bind(handle) + handle.dispose = async () => { await realDispose() throw new Error('grandchild reap failed') } @@ -3544,7 +3558,7 @@ describe('continuable errors', () => { expect(found).toBeDefined() return found! }) - const activations = continuationActivations(ctx) + const activations = managerState(ctx) const ownerAgents = activations.ownerCtx.agents const before = new Set(ctx.agents.list().map(agent => agent.id)) // Open the would-be parent's disposal only once the grandchild's Agent is diff --git a/packages/subagent/subagent/tests/external-activation.spec.ts b/packages/subagent/subagent/tests/external-activation.spec.ts index 6e14b61e5d..4acdef054c 100644 --- a/packages/subagent/subagent/tests/external-activation.spec.ts +++ b/packages/subagent/subagent/tests/external-activation.spec.ts @@ -8,7 +8,7 @@ import SubagentRuntime from '../src/index.ts' import { TestSessionQuery } from './test-session-query.ts' import type { SubagentStartRequest, SubagentCapabilities, SubagentResult, SubagentRun } from '../src/types.ts' import { externalTestParent } from './external-activation-helpers.ts' -import { continuationManager } from './continuation-internals.ts' +import { subagentManager } from './manager-internals.ts' const capabilities: SubagentCapabilities = { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false, @@ -91,7 +91,7 @@ describe('external subagent activations', () => { let drain = Promise.resolve() fixture.ctx.on('subagent/start', () => { if (change === 'drain') { - drain = continuationManager(fixture.ctx).drain() + drain = subagentManager(fixture.ctx).drain() } else { detachParent() if (change === 'parent-replace') { diff --git a/packages/subagent/subagent/tests/manager-internals.ts b/packages/subagent/subagent/tests/manager-internals.ts new file mode 100644 index 0000000000..50cd8cb76a --- /dev/null +++ b/packages/subagent/subagent/tests/manager-internals.ts @@ -0,0 +1,29 @@ +/** Package-private manager state used to place deterministic lifecycle races. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { Activation, ChildLock } from '../src/activation.ts' +import type SubagentManager from '../src/manager.ts' + +/** Return the service's bound subagent manager. */ +export function subagentManager(ctx: Context): SubagentManager { + const manager = (ctx.subagents as unknown as { + manager?: SubagentManager + }).manager + if (manager === undefined) throw new Error('expected a bound subagent manager') + return manager +} + +/** Read private lifecycle state for deterministic race placement. */ +export function managerState(ctx: Context) { + return subagentManager(ctx) as unknown as { + resident: Map + locks: ChildLock + ownerCtx: Context + } +} + +/** Remove only the map entry, leaving its Agent live for collision coverage. */ +export function dropActivation(ctx: Context, childId: SessionId): void { + managerState(ctx).resident.delete(childId) +} From 1ba45fb7b649f4556f597c38017416485149e78c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 12:02:22 +0800 Subject: [PATCH 031/402] test(subagent): retain typed access to manager internals --- .../subagent/subagent/tests/manager-internals.ts | 14 ++++++-------- scripts/no-unknown-casts.baseline.json | 5 ----- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/subagent/subagent/tests/manager-internals.ts b/packages/subagent/subagent/tests/manager-internals.ts index 50cd8cb76a..2bc8794430 100644 --- a/packages/subagent/subagent/tests/manager-internals.ts +++ b/packages/subagent/subagent/tests/manager-internals.ts @@ -2,24 +2,22 @@ import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { Activation, ChildLock } from '../src/activation.ts' import type SubagentManager from '../src/manager.ts' /** Return the service's bound subagent manager. */ export function subagentManager(ctx: Context): SubagentManager { - const manager = (ctx.subagents as unknown as { - manager?: SubagentManager - }).manager + const manager = ctx.subagents['manager'] if (manager === undefined) throw new Error('expected a bound subagent manager') return manager } /** Read private lifecycle state for deterministic race placement. */ export function managerState(ctx: Context) { - return subagentManager(ctx) as unknown as { - resident: Map - locks: ChildLock - ownerCtx: Context + const manager = subagentManager(ctx) + return { + resident: manager['resident'], + locks: manager['locks'], + ownerCtx: manager['ownerCtx'], } } diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index e9d85bebf6..667ec35c4c 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -2065,11 +2065,6 @@ "packages/subagent/subagent/tests/catalog.spec.ts": { "7e2e5f940dc4eaeefee0650619f7a736c5bead03fb248b2394c1a29a3328b105": 1 }, - "packages/subagent/subagent/tests/continuation-internals.ts": { - "3745fdae681f5fd4fa6825b3987604ceed308de0ef68181e9b3b8405a288b1b0": 1, - "ae5776af8c1e9965a4eca94bcbc07b65da8d2e9e24e4570d2141b34d60b4f90f": 1, - "dacc9babb26371c6c047d8a504e4c7da0e2032f83620ab63ec667276284d1c35": 1 - }, "packages/subagent/subagent/tests/continuation.spec.ts": { "ae5776af8c1e9965a4eca94bcbc07b65da8d2e9e24e4570d2141b34d60b4f90f": 1 }, From c17a1fa189c9adb92befa597ef43d258d4341f9b Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 15:03:34 +0800 Subject: [PATCH 032/402] refactor(subagent): represent external catalog entries by mode --- ...-17-unified-subagent-activations.i18n.yaml | 4 +- ...2026-09-17-unified-subagent-activations.md | 2 +- ...6-09-17-unified-subagent-activations.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 101 +- docs/persistence-catalog.zh.md | 101 +- ...-09-19-external-subagent-catalog.i18n.yaml | 4 +- .../2026-09-19-external-subagent-catalog.md | 8 +- ...9-19-external-subagent-catalog.schema.json | 102 +- ...2026-09-19-external-subagent-catalog.zh.md | 8 +- .../historical-formats/README.i18n.yaml | 4 +- .../historical-formats/README.md | 2 +- .../historical-formats/README.zh.md | 2 +- docs/persistence-schema.json | 1421 +++++++++-------- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 2 +- docs/subsystems/subagent.zh.md | 2 +- .../src/client/sessions/manager.ts | 4 +- .../src/client/sessions/service.ts | 2 +- .../tests/manager.client.spec.ts | 11 + .../src/client/SubagentHeaderLineage.tsx | 33 +- .../tests/conversation-ui.client.spec.tsx | 4 +- .../src/client/WorkflowRunPanel.tsx | 2 +- .../tests/workflow-run.client.spec.tsx | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- .../session-format-v3-to-v4/README.i18n.yaml | 4 +- .../session/session-format-v3-to-v4/README.md | 4 +- .../session-format-v3-to-v4/README.zh.md | 4 +- .../session-format-v3-to-v4/src/facts.ts | 5 +- .../tests/codec.spec.ts | 13 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/src/catalog.ts | 18 +- .../subagent/subagent/src/control-types.ts | 1 - packages/subagent/subagent/src/manager.ts | 2 +- .../subagent/subagent/src/projection-types.ts | 3 +- .../subagent/subagent/tests/catalog.spec.ts | 22 +- .../tests/external-activation.spec.ts | 8 +- .../tests/list-agents.spec.ts | 5 +- .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 2 +- .../test-support/client-runtime/README.zh.md | 2 +- .../client-runtime/src/sessions.ts | 2 +- .../tests/runtime.client.spec.tsx | 2 + snapshots/sdk/sdk.snapshot.ts | 2 +- .../notifications.expected.jsonl | 4 +- .../session.v4.jsonl | 4 +- .../notifications.expected.jsonl | 2 +- .../session.v4.jsonl | 2 +- .../session.v4.jsonl | 8 +- .../subagent-acp-diagnostic/session.v4.jsonl | 4 +- 52 files changed, 1110 insertions(+), 860 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.i18n.yaml index 61fb198175..0836c43071 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.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-09-17-unified-subagent-activations.md -2026-09-17-unified-subagent-activations.md: 197f8c20ebcc11014d4710cbb7f0c9335b57d53b -2026-09-17-unified-subagent-activations.zh.md: e774f7a7d149317a96b3d7e39026cfb2f180fafe +2026-09-17-unified-subagent-activations.md: 148b1ce4009bd40729835a8bb7cb079238c3cfa7 +2026-09-17-unified-subagent-activations.zh.md: fd36c19f3f8bbcdaf2eec0c64200339d49ba378c diff --git a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md index 197f8c20eb..148b1ce400 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md +++ b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.md @@ -14,7 +14,7 @@ Local one-shot execution and continuable residency separately owned child creati Local spawn and fork providers contribute only `prepareContinuable()` data; the subagent service owns Agent creation and residency. The standalone in-process driver package is removed. Structured tools, validation, instructions, and terminal guards attach to one activation and close with it. After capture, that activation rejects further input; cold resume reconstructs an ordinary conversation without the previous schema. -ACP, DSH SDK, Codex, and Claude Code retain their existing single-execution provider adapters. They join the same activation capacity and ownership graph without accepting follow-up messages or fabricating a local child Session. The parent-owned catalog registers both local children and external leaves. External entries carry `external: true` because they have no local Session to open. Creation records membership once; execution and cleanup do not update the catalog. Execution status has no required consumer and remains outside the membership record. Caller delivery returns the complete result; parent delivery queues the complete completion notice. +ACP, DSH SDK, Codex, and Claude Code retain their existing single-execution provider adapters. They join the same activation capacity and ownership graph without accepting follow-up messages or fabricating a local child Session. The parent-owned catalog registers both local children and external leaves. External entries carry `mode: 'external'` because they have no local Session to open. Creation records membership once; execution and cleanup do not update the catalog. Execution status has no required consumer and remains outside the membership record. Caller delivery returns the complete result; parent delivery queues the complete completion notice. The model-facing tool always returns a background child id and promises the manager's completion notice. It has no foreground switch or Job integration. Workflows select caller delivery, await the activation result, and dispose the handle before finishing; they add neither a completion notice nor initial return guidance to the parent/child exchange. Headless completion waits for its own child tree and subsequent parent turns. diff --git a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md index e774f7a7d1..fd36c19f3f 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-17-unified-subagent-activations.zh.md @@ -14,7 +14,7 @@ Status: implemented 本地 spawn 和 fork 提供方只贡献 `prepareContinuable()` 数据,由 subagent 服务拥有 Agent 创建与驻留。独立进程内驱动器包被移除。结构化工具、校验、指令与终止保护附着于一次 activation,并随其关闭。捕获后该 activation 拒绝继续输入;冷恢复重建普通对话,不携带此前的 schema。 -ACP、DSH SDK、Codex 和 Claude Code 保留单次执行适配器,共享 activation 容量与所有权,不接受后续消息,也不伪造本地子 Session。父级拥有的 catalog 同时登记本地子代理与外部叶子项。外部条目携带 `external: true`,因为没有可打开的本地 Session。创建时记录一次成员关系;执行与清理不更新目录。执行状态没有必需的消费者,不属于成员记录。caller 交付返回完整结果,parent 交付将完整完成通知加入父级队列。 +ACP、DSH SDK、Codex 和 Claude Code 保留单次执行适配器,共享 activation 容量与所有权,不接受后续消息,也不伪造本地子 Session。父级拥有的 catalog 同时登记本地子代理与外部叶子项。外部条目携带 `mode: 'external'`,因为没有可打开的本地 Session。创建时记录一次成员关系;执行与清理不更新目录。执行状态没有必需的消费者,不属于成员记录。caller 交付返回完整结果,parent 交付将完整完成通知加入父级队列。 面向模型的工具始终返回后台子级 ID,并承诺管理器的完成通知。它没有前台开关或 Job 集成。工作流选择调用方投递,等待 activation 结果并在完成前释放句柄;它们不向父子交互添加完成通知或初始返回指导。Headless 完成流程等待其自身子树与后续父级轮次。 diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index b0e3d5747b..798b10771b 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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/persistence-catalog.md -persistence-catalog.md: 8e448cc128bd4f65c3f02ff1a03cba8c5d764f99 -persistence-catalog.zh.md: 6c29453dfe5d00031f9a10741667f6c4cc525f3f +persistence-catalog.md: 25b55710e41c3e24c6534fc25186e8550657b490 +persistence-catalog.zh.md: d6b6021e35504afb10e88b01930b54f99b748a7b diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8e448cc128..25b55710e4 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -57,7 +57,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada) | +| `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -900,7 +900,7 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ 'subagent/catalog': SubagentCatalogEvent ``` -Source: [`packages/subagent/subagent/src/catalog.ts:43`](../packages/subagent/subagent/src/catalog.ts) +Source: [`packages/subagent/subagent/src/catalog.ts:44`](../packages/subagent/subagent/src/catalog.ts) @@ -1725,6 +1725,14 @@ SHA-256: `e42b0f40b5d0b628c6e1c44a8fb9eda0780037a81959e1b93d2dfb47b46b0e87` `"every"` + + +### `"external"` + +SHA-256: `fc351843b8ecbd05d739c4ca4fcd4bc7cfad6c0beacbbd48389959bb1bf06cf3` + +`"external"` + ### `"failed"` @@ -4555,7 +4563,7 @@ One of: - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4563,17 +4571,18 @@ One of: ### `SubagentCatalogEvent` -SHA-256: `4e6c80858802af53690bae54e01a4e416c7aec5b34a8817b21d6cf33a9603b52` +SHA-256: `f731ffe80810b0289b3e694d8f40af6f01865c02a622ee457be90ca808b66e87` Sources: [`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) One of: -- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) -- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-7e25366aed8235508ef657343f0f263f3354b8418406dcd857acecd7e1c74cd0) -- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-718c94fb5c5ac43cdd163d384bd75b3ca8bc355a02727423cd42d97948263128) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) @@ -5609,36 +5618,6 @@ SHA-256: `df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18` Array of [`{ capturedFormatVersion?, capturedThroughSeq, compacted, inputIndex, … }`](#persistence-type-sha256-4f91f187baeabef5c7950c4780d31bbe39f3345dff591a6cf26ac7e135ac1cf4). - - -### `{ childCreatedAt, childId, external?, label?, … }` - -SHA-256: `718c94fb5c5ac43cdd163d384bd75b3ca8bc355a02727423cd42d97948263128` - -| Property | Presence | Type | -|---|---|---| -| `childCreatedAt` | required | `number` | -| `childId` | required | `string` | -| `external` | optional | `true` | -| `label` | optional | `string` | -| `mode` | required | `"one-shot"` | -| `version` | required | `1` | - - - -### `{ childCreatedAt, childId, external?, label?, … }` - -SHA-256: `7e25366aed8235508ef657343f0f263f3354b8418406dcd857acecd7e1c74cd0` - -| Property | Presence | Type | -|---|---|---| -| `childCreatedAt` | required | `number` | -| `childId` | required | `string` | -| `external` | optional | `true` | -| `label` | optional | `string` | -| `mode` | required | `"one-shot"` | -| `version` | required | `0` | - ### `{ childCreatedAt, childId, label, mode, … }` @@ -5667,6 +5646,20 @@ SHA-256: `e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187` | `mode` | required | `"continuable"` | | `version` | required | `1` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"one-shot"` | +| `version` | required | `0` | + ### `{ childCreatedAt, childId, label?, mode, … }` @@ -5681,6 +5674,34 @@ SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` | `mode` | required | `"unknown"` | | `version` | required | `1` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"one-shot"` | +| `version` | required | `1` | + + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"external"` | +| `version` | required | `2` | + ### `{ code, name, reason? }` @@ -8276,17 +8297,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | required | `number` | | `type` | required | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada` +SHA-256: `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-4e6c80858802af53690bae54e01a4e416c7aec5b34a8817b21d6cf33a9603b52) | +| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-f731ffe80810b0289b3e694d8f40af6f01865c02a622ee457be90ca808b66e87) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 6c29453dfe..d6b6021e35 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -59,7 +59,7 @@ | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada) | +| `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -902,7 +902,7 @@ export type SessionEvent = { 'subagent/catalog': SubagentCatalogEvent ``` -来源:[`packages/subagent/subagent/src/catalog.ts:43`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:44`](../packages/subagent/subagent/src/catalog.ts) @@ -1727,6 +1727,14 @@ SHA-256: `e42b0f40b5d0b628c6e1c44a8fb9eda0780037a81959e1b93d2dfb47b46b0e87` `"every"` + + +### `"external"` + +SHA-256: `fc351843b8ecbd05d739c4ca4fcd4bc7cfad6c0beacbbd48389959bb1bf06cf3` + +`"external"` + ### `"failed"` @@ -4557,7 +4565,7 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4565,17 +4573,18 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` ### `SubagentCatalogEvent` -SHA-256: `4e6c80858802af53690bae54e01a4e416c7aec5b34a8817b21d6cf33a9603b52` +SHA-256: `f731ffe80810b0289b3e694d8f40af6f01865c02a622ee457be90ca808b66e87` 来源:[`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) 以下类型之一: -- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) -- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-7e25366aed8235508ef657343f0f263f3354b8418406dcd857acecd7e1c74cd0) -- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-718c94fb5c5ac43cdd163d384bd75b3ca8bc355a02727423cd42d97948263128) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) @@ -5611,36 +5620,6 @@ SHA-256: `df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18` [`{ capturedFormatVersion?, capturedThroughSeq, compacted, inputIndex, … }`](#persistence-type-sha256-4f91f187baeabef5c7950c4780d31bbe39f3345dff591a6cf26ac7e135ac1cf4) 的数组。 - - -### `{ childCreatedAt, childId, external?, label?, … }` - -SHA-256: `718c94fb5c5ac43cdd163d384bd75b3ca8bc355a02727423cd42d97948263128` - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `childCreatedAt` | 必需 | `number` | -| `childId` | 必需 | `string` | -| `external` | 可选 | `true` | -| `label` | 可选 | `string` | -| `mode` | 必需 | `"one-shot"` | -| `version` | 必需 | `1` | - - - -### `{ childCreatedAt, childId, external?, label?, … }` - -SHA-256: `7e25366aed8235508ef657343f0f263f3354b8418406dcd857acecd7e1c74cd0` - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `childCreatedAt` | 必需 | `number` | -| `childId` | 必需 | `string` | -| `external` | 可选 | `true` | -| `label` | 可选 | `string` | -| `mode` | 必需 | `"one-shot"` | -| `version` | 必需 | `0` | - ### `{ childCreatedAt, childId, label, mode, … }` @@ -5669,6 +5648,20 @@ SHA-256: `e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187` | `mode` | 必需 | `"continuable"` | | `version` | 必需 | `1` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"one-shot"` | +| `version` | 必需 | `0` | + ### `{ childCreatedAt, childId, label?, mode, … }` @@ -5683,6 +5676,34 @@ SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` | `mode` | 必需 | `"unknown"` | | `version` | 必需 | `1` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"one-shot"` | +| `version` | 必需 | `1` | + + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"external"` | +| `version` | 必需 | `2` | + ### `{ code, name, reason? }` @@ -8278,17 +8299,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | 必需 | `number` | | `type` | 必需 | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada` +SHA-256: `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-4e6c80858802af53690bae54e01a4e416c7aec5b34a8817b21d6cf33a9603b52) | +| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-f731ffe80810b0289b3e694d8f40af6f01865c02a622ee457be90ca808b66e87) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | diff --git a/docs/persistence-changes/2026-09-19-external-subagent-catalog.i18n.yaml b/docs/persistence-changes/2026-09-19-external-subagent-catalog.i18n.yaml index 2a9ff18bf9..2118d500b3 100644 --- a/docs/persistence-changes/2026-09-19-external-subagent-catalog.i18n.yaml +++ b/docs/persistence-changes/2026-09-19-external-subagent-catalog.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/persistence-changes/2026-09-19-external-subagent-catalog.md -2026-09-19-external-subagent-catalog.md: 3ca24aec51d7aaaf1cb7421ab90032df4f5d8b20 -2026-09-19-external-subagent-catalog.zh.md: 34f0d4a08722376c1d5223adbbac71ed06f211c1 +2026-09-19-external-subagent-catalog.md: 36d0441e8d6369b44b1d1a6c64397e26c37f5be6 +2026-09-19-external-subagent-catalog.zh.md: 7586e8c2d1358cb0418710b9aa6c0ad02cf5fefd diff --git a/docs/persistence-changes/2026-09-19-external-subagent-catalog.md b/docs/persistence-changes/2026-09-19-external-subagent-catalog.md index 3ca24aec51..36d0441e8d 100644 --- a/docs/persistence-changes/2026-09-19-external-subagent-catalog.md +++ b/docs/persistence-changes/2026-09-19-external-subagent-catalog.md @@ -9,7 +9,7 @@ English | [中文](2026-09-19-external-subagent-catalog.zh.md) ## Summary -Add an optional external membership marker to one-shot parent catalog entries. +Add external mode to parent catalog entries for children without a local Session. ## Table of Contents @@ -28,19 +28,19 @@ baseline: false changes: - root: "event:subagent/catalog" previous: "2026-09-20-unknown-child-catalog" - after: "568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada" + after: "53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac" decision: same-version ``` ## Compatibility -Existing version-0 and version-1 catalog facts remain valid without the optional external field. The literal true identifies a child without a local Session. Creation writes one membership fact; execution and cleanup do not update it. Duplicate child ids remain invalid. Unknown-mode and continuable entries do not accept the marker. This updates the unaccepted optional-field addition in this PR and does not change released generations or the Session format version. +Existing version-0 and version-1 one-shot and continuable facts remain valid; version 1 also retains unknown mode. New external executions write payload version 2 with mode external, without a separate marker. Creation writes one membership fact; execution and cleanup do not update it. Duplicate child ids remain invalid. This replaces the unaccepted external-marker addition in this PR and does not change released generations or the Session format version. ## Verification -Focused catalog, external activation, native V4 restoration, and client tests passed. They cover creation-time membership, no settlement update, cleanup failure independent of catalog membership, and duplicate-child rejection. +Focused catalog, external activation, native V4 restoration, client navigation, and control-tool tests passed. They cover external membership, historical local modes, invalid marker rejection, non-navigable external leaves, and duplicate-child rejection. ## Dev Note diff --git a/docs/persistence-changes/2026-09-19-external-subagent-catalog.schema.json b/docs/persistence-changes/2026-09-19-external-subagent-catalog.schema.json index acdecbf08c..337379c605 100644 --- a/docs/persistence-changes/2026-09-19-external-subagent-catalog.schema.json +++ b/docs/persistence-changes/2026-09-19-external-subagent-catalog.schema.json @@ -6,7 +6,7 @@ "kind": "event", "event": "subagent/catalog", "surface": false, - "digest": "568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada", + "digest": "53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac", "schema": { "root": 0, "nodes": [ @@ -20,7 +20,7 @@ }, { "name": "ignorable", - "type": 5, + "type": 17, "optional": true }, { @@ -35,7 +35,7 @@ }, { "name": "type", - "type": 15, + "type": 18, "optional": false } ], @@ -45,10 +45,11 @@ "kind": "union", "types": [ 2, - 8, - 10, + 7, + 9, 12, - 13 + 14, + 15 ] }, { @@ -64,24 +65,19 @@ "type": 4, "optional": false }, - { - "name": "external", - "type": 5, - "optional": true - }, { "name": "label", "type": 4, - "optional": true + "optional": false }, { "name": "mode", - "type": 6, + "type": 5, "optional": false }, { "name": "version", - "type": 7, + "type": 6, "optional": false } ], @@ -97,11 +93,7 @@ }, { "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "one-shot" + "value": "continuable" }, { "kind": "literal", @@ -120,24 +112,19 @@ "type": 4, "optional": false }, - { - "name": "external", - "type": 5, - "optional": true - }, { "name": "label", "type": 4, - "optional": true + "optional": false }, { "name": "mode", - "type": 6, + "type": 5, "optional": false }, { "name": "version", - "type": 9, + "type": 8, "optional": false } ], @@ -163,16 +150,16 @@ { "name": "label", "type": 4, - "optional": false + "optional": true }, { "name": "mode", - "type": 11, + "type": 10, "optional": false }, { "name": "version", - "type": 7, + "type": 11, "optional": false } ], @@ -180,7 +167,11 @@ }, { "kind": "literal", - "value": "continuable" + "value": "external" + }, + { + "kind": "literal", + "value": 2 }, { "kind": "object", @@ -198,16 +189,51 @@ { "name": "label", "type": 4, - "optional": false + "optional": true }, { "name": "mode", - "type": 11, + "type": 13, "optional": false }, { "name": "version", - "type": 9, + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, "optional": false } ], @@ -233,12 +259,12 @@ }, { "name": "mode", - "type": 14, + "type": 16, "optional": false }, { "name": "version", - "type": 9, + "type": 8, "optional": false } ], @@ -248,6 +274,10 @@ "kind": "literal", "value": "unknown" }, + { + "kind": "literal", + "value": true + }, { "kind": "literal", "value": "subagent/catalog" diff --git a/docs/persistence-changes/2026-09-19-external-subagent-catalog.zh.md b/docs/persistence-changes/2026-09-19-external-subagent-catalog.zh.md index 34f0d4a087..7586e8c2d1 100644 --- a/docs/persistence-changes/2026-09-19-external-subagent-catalog.zh.md +++ b/docs/persistence-changes/2026-09-19-external-subagent-catalog.zh.md @@ -9,7 +9,7 @@ kind: persistence-change ## 概述 -为一次性父级目录条目增加可选的外部成员标记。 +为父级目录中没有本地 Session 的子级添加 external 模式。 ## 目录 @@ -28,19 +28,19 @@ baseline: false changes: - root: "event:subagent/catalog" previous: "2026-09-20-unknown-child-catalog" - after: "568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada" + after: "53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac" decision: same-version ``` ## 兼容性 -已有 version-0 与 version-1 目录事实无需 external 字段仍然有效。字面量 true 标记没有本地 Session 的子级。创建时写入一次成员事实,执行与清理均不更新该记录。重复子 id 仍然无效。未知模式与可继续条目不接受此标记。本次更新本 PR 尚未接受的可选字段新增记录,不改变已发布世代或 Session 格式版本。 +已有 version-0 和 version-1 的 one-shot 与 continuable 事实仍然有效;version 1 还保留 unknown 模式。新外部执行写入 payload version 2 和 external 模式,不使用单独的标记。创建时写入一次成员事实,执行和清理不更新它。重复的子 id 仍然无效。这替换本 PR 中尚未接受的 external 标记新增项,不改变已发布的代际或 Session 格式版本。 ## 验证 -聚焦的目录、外部 activation、原生 V4 恢复与客户端测试通过,覆盖创建时成员登记、不发布结算更新、清理失败独立于目录成员关系以及拒绝重复子级。 +定向 catalog、外部 activation、原生 V4 恢复、客户端导航和控制工具测试通过,覆盖外部成员关系、历史本地模式、非法标记拒绝、不可导航的外部叶节点,以及重复子级拒绝。 ## 开发备注 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 1f6bfd547a..62def62200 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/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 docs/persistence-changes/historical-formats/README.md -README.md: a1064e815879d709fe466441bb4e8441c10b3aae -README.zh.md: 87c9d32fa6ca5248886dad663a926afc6983406f +README.md: 453137b6f196b5572973d793f6648220b2366571 +README.zh.md: a64ce4f1e030f35b831afc794625c0ec79ab394b diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index a1064e8158..453137b6f1 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 587 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 589 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 87c9d32fa6..a64ce4f1e0 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 587 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 589 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 72a6d46a56..fba43e9a0b 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -14850,7 +14850,7 @@ "kind": "event", "event": "subagent/catalog", "surface": false, - "digest": "568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada", + "digest": "53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac", "schema": { "root": 0, "nodes": [ @@ -14864,7 +14864,7 @@ }, { "name": "ignorable", - "type": 5, + "type": 17, "optional": true }, { @@ -14879,7 +14879,7 @@ }, { "name": "type", - "type": 15, + "type": 18, "optional": false } ], @@ -14889,10 +14889,11 @@ "kind": "union", "types": [ 2, - 8, - 10, + 7, + 9, 12, - 13 + 14, + 15 ] }, { @@ -14908,24 +14909,19 @@ "type": 4, "optional": false }, - { - "name": "external", - "type": 5, - "optional": true - }, { "name": "label", "type": 4, - "optional": true + "optional": false }, { "name": "mode", - "type": 6, + "type": 5, "optional": false }, { "name": "version", - "type": 7, + "type": 6, "optional": false } ], @@ -14941,11 +14937,7 @@ }, { "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "one-shot" + "value": "continuable" }, { "kind": "literal", @@ -14964,24 +14956,19 @@ "type": 4, "optional": false }, - { - "name": "external", - "type": 5, - "optional": true - }, { "name": "label", "type": 4, - "optional": true + "optional": false }, { "name": "mode", - "type": 6, + "type": 5, "optional": false }, { "name": "version", - "type": 9, + "type": 8, "optional": false } ], @@ -15007,16 +14994,16 @@ { "name": "label", "type": 4, - "optional": false + "optional": true }, { "name": "mode", - "type": 11, + "type": 10, "optional": false }, { "name": "version", - "type": 7, + "type": 11, "optional": false } ], @@ -15024,7 +15011,11 @@ }, { "kind": "literal", - "value": "continuable" + "value": "external" + }, + { + "kind": "literal", + "value": 2 }, { "kind": "object", @@ -15042,16 +15033,51 @@ { "name": "label", "type": 4, - "optional": false + "optional": true }, { "name": "mode", - "type": 11, + "type": 13, "optional": false }, { "name": "version", - "type": 9, + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, "optional": false } ], @@ -15077,12 +15103,12 @@ }, { "name": "mode", - "type": 14, + "type": 16, "optional": false }, { "name": "version", - "type": 9, + "type": 8, "optional": false } ], @@ -15092,6 +15118,10 @@ "kind": "literal", "value": "unknown" }, + { + "kind": "literal", + "value": true + }, { "kind": "literal", "value": "subagent/catalog" @@ -44222,227 +44252,6 @@ "names": [], "sources": [] }, - { - "digest": "4e6c80858802af53690bae54e01a4e416c7aec5b34a8817b21d6cf33a9603b52", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "union", - "types": [ - 1, - 6, - 8, - 10, - 13 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": false - }, - { - "name": "mode", - "type": 4, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": false - }, - { - "name": "mode", - "type": 4, - "optional": false - }, - { - "name": "version", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": 1 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", - "type": 9, - "optional": false - }, - { - "name": "version", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "unknown" - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "external", - "type": 11, - "optional": true - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", - "type": 12, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "external", - "type": 11, - "optional": true - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", - "type": 12, - "optional": false - }, - { - "name": "version", - "type": 7, - "optional": false - } - ], - "indices": [] - } - ] - }, - "names": [ - "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" - ], - "sources": [ - "packages/subagent/subagent/src/catalog.ts:28" - ] - }, { "digest": "4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c", "schema": { @@ -48581,6 +48390,288 @@ "packages/core/session/src/types.ts:273" ] }, + { + "digest": "53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 17, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 7, + 9, + 12, + 14, + 15 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 11, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "external" + }, + { + "kind": "literal", + "value": 2 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 16, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "53cf1704ae0709c7678bcf070c83c619fc48ca1b5c173b74cfa8702f22a8e492", "schema": { @@ -49572,258 +49663,6 @@ "names": [], "sources": [] }, - { - "digest": "568d482eeba04a9422773e7563cf935bd06f16896758cf880cf19b6bab8b1ada", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 5, - "optional": true - }, - { - "name": "seq", - "type": 3, - "optional": false - }, - { - "name": "time", - "type": 3, - "optional": false - }, - { - "name": "type", - "type": 15, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "union", - "types": [ - 2, - 8, - 10, - 12, - 13 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "external", - "type": 5, - "optional": true - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 6, - "optional": false - }, - { - "name": "version", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "external", - "type": 5, - "optional": true - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 6, - "optional": false - }, - { - "name": "version", - "type": 9, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": 1 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": false - }, - { - "name": "mode", - "type": 11, - "optional": false - }, - { - "name": "version", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": false - }, - { - "name": "mode", - "type": 11, - "optional": false - }, - { - "name": "version", - "type": 9, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 14, - "optional": false - }, - { - "name": "version", - "type": 9, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "unknown" - }, - { - "kind": "literal", - "value": "subagent/catalog" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23", "schema": { @@ -51007,6 +50846,63 @@ "names": [], "sources": [] }, + { + "digest": "5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 0 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "5d7a64ed6aac5551ebf998dc25e1e7b48a144769913f89702840180e61dc3995", "schema": { @@ -59143,72 +59039,6 @@ "names": [], "sources": [] }, - { - "digest": "718c94fb5c5ac43cdd163d384bd75b3ca8bc355a02727423cd42d97948263128", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 1, - "optional": false - }, - { - "name": "childId", - "type": 2, - "optional": false - }, - { - "name": "external", - "type": 3, - "optional": true - }, - { - "name": "label", - "type": 2, - "optional": true - }, - { - "name": "mode", - "type": 4, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": 1 - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91", "schema": { @@ -60977,72 +60807,6 @@ "names": [], "sources": [] }, - { - "digest": "7e25366aed8235508ef657343f0f263f3354b8418406dcd857acecd7e1c74cd0", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 1, - "optional": false - }, - { - "name": "childId", - "type": 2, - "optional": false - }, - { - "name": "external", - "type": 3, - "optional": true - }, - { - "name": "label", - "type": 2, - "optional": true - }, - { - "name": "mode", - "type": 4, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": 0 - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "7eaef7a763ba5b949b8464bc8add8980a3a5ab566014b69787aeba71e5dfe07b", "schema": { @@ -72287,6 +72051,63 @@ "names": [], "sources": [] }, + { + "digest": "b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0", "schema": { @@ -74261,6 +74082,63 @@ "names": [], "sources": [] }, + { + "digest": "cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "external" + }, + { + "kind": "literal", + "value": 2 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "cbca49bde16b656d5b8fbe4a8a507769f7b594c6287782b809c03814941bc5e3", "schema": { @@ -90406,6 +90284,253 @@ "names": [], "sources": [] }, + { + "digest": "f731ffe80810b0289b3e694d8f40af6f01865c02a622ee457be90ca808b66e87", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "union", + "types": [ + 1, + 6, + 9, + 12, + 13, + 15 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 7, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "external" + }, + { + "kind": "literal", + "value": 2 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 11, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 14, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 11, + "optional": false + } + ], + "indices": [] + } + ] + }, + "names": [ + "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" + ], + "sources": [ + "packages/subagent/subagent/src/catalog.ts:28" + ] + }, { "digest": "f789af5ab844827eb5a4f357c0afae1eeed108c6c22d8ca1a56e586b349a7c85", "schema": { @@ -93976,6 +94101,20 @@ "packages/skill/tool-skill/src/index.ts:34" ] }, + { + "digest": "fc351843b8ecbd05d739c4ca4fcd4bc7cfad6c0beacbbd48389959bb1bf06cf3", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "literal", + "value": "external" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "fc8242857caf7450d7f68a1f509dce83e237bd21571666006895124cb2246a83", "schema": { diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index a1273b2293..400ce1fb78 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/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/subsystems/subagent.md -subagent.md: 75164dbde60fd53d1ab8d1783c6c40d01b4f8868 -subagent.zh.md: 2ee5b2cb12178b33c5980cfbb727e3fbc907d640 +subagent.md: aa93d2bc5f32233acfa1333556e78819a14f2eaf +subagent.zh.md: 6c0862ff2ce154991a0242ef6bc8e058b6ff6e1d diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 75164dbde6..aa93d2bc5f 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -222,7 +222,7 @@ type SubagentInterruptAuthority = ## Durable enumeration -`listChildren` reads the parent-owned catalog. `listDescendants` combines Session corpus identity with parent catalogs, including external leaves beneath ordinary Sessions. External one-shot entries carry `external: true` and cannot open a child Session or accept follow-ups. Catalog entries record membership at creation without execution status. Complete results go to the caller or the parent completion notice. `list_agents` exposes only continuable children with `running` or `inactive` activity; see the [control tool](../../packages/subagent/tool-subagent-control/README.md#list_agents) for its scopes and diagnostics. +`listChildren` reads the parent-owned catalog. `listDescendants` enumerates Session-backed children through corpus identity. External entries carry `mode: 'external'` and cannot open a child Session or accept follow-ups. Catalog entries record membership at creation without execution status. Complete results go to the caller or the parent completion notice. `list_agents` exposes only continuable children with `running` or `inactive` activity; see the [control tool](../../packages/subagent/tool-subagent-control/README.md#list_agents) for its scopes and diagnostics. ```ts type-equiv /** diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 2ee5b2cb12..6c0862ff2c 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -222,7 +222,7 @@ type SubagentInterruptAuthority = ## 持久枚举 -`listChildren` 读取父级拥有的目录。`listDescendants` 结合 Session 语料中的身份与父级目录,包括普通 Session 下的外部叶子项。外部一次性条目携带 `external: true`,不能打开子 Session 或接受后续消息。目录在创建时记录成员关系,不携带执行状态。完整结果交给调用方或父级完成通知。`list_agents` 只展示可继续子级,其活跃状态为 `running` 或 `inactive`;作用域与诊断信息见[控制工具](../../packages/subagent/tool-subagent-control/README.zh.md#list_agents)。 +`listChildren` 读取父级拥有的目录。`listDescendants` 通过语料中的身份枚举具有 Session 的子级。外部条目携带 `mode: 'external'`,不能打开子 Session 或接受后续消息。目录在创建时记录成员关系,不携带执行状态。完整结果交给调用方或父级完成通知。`list_agents` 只展示可继续子级,其活跃状态为 `running` 或 `inactive`;作用域与诊断信息见[控制工具](../../packages/subagent/tool-subagent-control/README.zh.md#list_agents)。 ```ts type-equiv /** diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 0c601f849a..d286c75626 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -169,7 +169,7 @@ export class SessionManager { for (const parentSessionId of this.projectionStores.keys()) { const child = this.projectionStores.get(parentSessionId)?.values().subagentCatalog ?.find(entry => entry.id === sessionId) - if (child !== undefined && !(child.mode === 'one-shot' && child.external !== undefined)) { + if (child !== undefined && child.mode !== 'external') { return { parentSessionId, childSessionId: sessionId, @@ -255,7 +255,7 @@ export class SessionManager { const address = this.addresses.get(sessionId) const child = address === undefined ? undefined : this.projectionStores.get(address.parentSessionId)?.values().subagentCatalog ?.find(entry => entry.id === sessionId) - if (child !== undefined && !(child.mode === 'one-shot' && child.external !== undefined)) { + if (child !== undefined && child.mode !== 'external') { // A catalogued child exists only after its delegated session has // durable history, even though child rows do not carry `blank`. session.handleBlank(false) diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 610aace871..3385247993 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -638,7 +638,7 @@ export class ClientSessions implements ISessions { } for (const [parentId, projection] of Object.entries(projectionsBySession)) { for (const child of projection.values.subagentCatalog ?? []) { - if (child.mode === 'one-shot' && child.external !== undefined) continue + if (child.mode === 'external') continue const childId = child.id const summary = byId[childId] const projectionValues = summary?.projectionValues ?? this.manager.projectionValues(childId) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 55d182110a..7b8aa25b4c 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -733,6 +733,17 @@ describe('Host Remote event routing', () => { }) describe('subagent catalogs', () => { + it('retains external membership without creating a local browsing address', ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + const entries = [{ id: S2, createdAt: 1, mode: 'external' as const, label: 'external task' }] + manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0, value: entries }) + + expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual(entries) + expect(manager.subagentAddress(S2)).toBeUndefined() + expect(manager.getListSnapshot().items).toEqual([]) + }) + it('keeps a catalog-discovered child address across identity resolution and status frames', async ({ mock, remote, start }) => { remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [ summary(S1), diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 0c9cc0ea58..951f4b4420 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -210,7 +210,7 @@ function CatalogRows({ return () => { clearInterval(timer) } }, [running]) const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0 - const reserveDisclosure = catalog.entries.some(entry => !(entry.mode === 'one-shot' && entry.external !== undefined) && !isKnownLeaf(catalogs[entry.id])) + const reserveDisclosure = catalog.entries.some(entry => entry.mode !== 'external' && !isKnownLeaf(catalogs[entry.id])) return ( <> {emptyLoading && ( @@ -230,20 +230,20 @@ function CatalogRows({ )} {catalog.entries.map((entry) => { - const external = entry.mode === 'one-shot' ? entry.external : undefined + const external = entry.mode === 'external' const childCatalog = catalogs[entry.id] const isCurrent = entry.id === currentSessionId const isExpanded = expanded.has(entry.id) - const knownLeaf = external !== undefined || isKnownLeaf(childCatalog) + const knownLeaf = external || isKnownLeaf(childCatalog) const childLoading = childCatalog === undefined || (childCatalog.state === 'loading' && childCatalog.entries.length === 0) const summary = summaries[entry.id] const label = entry.label ?? entry.id - const mode = external !== undefined ? t('mode.external') : entry.mode === 'unknown' ? t('mode.unknown') + const mode = external ? t('mode.external') : entry.mode === 'unknown' ? t('mode.unknown') : entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') - const completed = external === undefined && entry.activity === 'inactive' + const completed = !external && entry.activity === 'inactive' && summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true - const activity = external !== undefined ? undefined : entry.activity === 'running' + const activity = external ? undefined : entry.activity === 'running' ? t('activity.running') : completed ? t('activity.completed') @@ -271,7 +271,7 @@ function CatalogRows({ .join(' · ') const open = (): void => { - if (external !== undefined) return + if (external) return openChild({ parentSessionId, childSessionId: entry.id, @@ -279,12 +279,6 @@ function CatalogRows({ }) closeCatalog() } - const openAside = (event: MouseEvent): void => { - event.preventDefault() - event.stopPropagation() - openChildAside({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) - closeCatalog() - } const handleKey = (event: KeyboardEvent): void => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() @@ -309,8 +303,8 @@ function CatalogRows({
value !== '').join(' ')} @@ -353,13 +347,18 @@ function CatalogRows({ )} )} - {!isCurrent && external === undefined && ( + {!isCurrent && !external && (